mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-07-30 10:29:07 +00:00
stuff
This commit is contained in:
parent
55278329ef
commit
c2de292527
|
|
@ -3,13 +3,20 @@ module.exports = {
|
|||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
ignorePatterns: ["dist"],
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true
|
||||
}
|
||||
es2022: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# GitHub Sponsors configuration
|
||||
github: awalsh128
|
||||
62
.github/actions/setup/action.yml
vendored
62
.github/actions/setup/action.yml
vendored
|
|
@ -1,15 +1,6 @@
|
|||
name: "Setup environment"
|
||||
description: "Checks out code, sets up Node.js, and builds the project or fetches cached build artifacts, caching build artifacts for faster subsequent runs"
|
||||
|
||||
inputs:
|
||||
operation:
|
||||
description: "The operation to perform. Can be 'build' or 'fetch' (default: 'fetch')."
|
||||
required: true
|
||||
default: "fetch"
|
||||
values:
|
||||
- "build"
|
||||
- "fetch"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
|
|
@ -19,49 +10,34 @@ runs:
|
|||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Get Bundle Artifacts Name
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci
|
||||
|
||||
- name: Create Build Artifacts Name
|
||||
id: get-artifacts-name
|
||||
shell: bash
|
||||
env:
|
||||
bundle_artifacts_name: "action-bundle-${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
build_artifacts_name: "build-dist-${{ github.sha }}"
|
||||
run: |
|
||||
echo "bundle-artifacts-name=${{ env.bundle_artifacts_name }}" >> $GITHUB_ENV
|
||||
echo "build-artifacts-name=${{ env.build_artifacts_name }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Try Fetch Bundle Artifacts
|
||||
if: ${{ inputs.operation == 'fetch' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
- name: Try Download Build Artifacts
|
||||
id: try-download-artifact
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
name: ${{ env.bundle-artifacts-name }}
|
||||
name: ${{ env.build-artifacts-name }}
|
||||
if_no_artifact_found: ignore
|
||||
|
||||
- name: Check Fetch Bundle Artifacts
|
||||
id: check-artifact
|
||||
- name: Build
|
||||
if: steps.try-download-artifact.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d "dist" ] && [ "$(ls -A dist)" ]; then
|
||||
echo "artifacts_present=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "artifacts_present=false" >> $GITHUB_ENV
|
||||
fi
|
||||
run: npm run build # Generates dist/ folder
|
||||
|
||||
- name: Fail Fetch Bundle Artifacts
|
||||
if: ${{ inputs.operation == 'fetch' && env.artifacts_present == 'false' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "No artifacts found for the current commit. Please run the workflow with 'build' operation first."
|
||||
exit 1
|
||||
|
||||
- name: Bundle Action
|
||||
if: ${{ inputs.operation == 'build' && env.artifacts_present == 'false' }}
|
||||
shell: bash
|
||||
run: npm run bundle
|
||||
|
||||
- name: Upload Bundle Artifacts
|
||||
if: ${{ inputs.operation == 'build' && env.artifacts_present == 'false' }}
|
||||
- name: Upload Build Artifacts
|
||||
if: steps.try-download-artifact.outcome == 'failure'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.bundle-artifacts-name }}
|
||||
path: |
|
||||
action.yml
|
||||
dist/
|
||||
name: ${{ env.build-artifacts-name }}
|
||||
path: dist
|
||||
retention-days: 1
|
||||
|
|
|
|||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
|
|
@ -90,6 +90,25 @@ jobs:
|
|||
shell: bash
|
||||
run: npm run typecheck
|
||||
|
||||
action-deps-pinned:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-and-cache
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Check Action Dependencies Pinned
|
||||
shell: bash
|
||||
run: npm run check:latest-action-pin ./action.yml
|
||||
|
||||
testing:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-and-cache
|
||||
|
|
|
|||
25
.github/workflows/pr.yml
vendored
Normal file
25
.github/workflows/pr.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Pull Requests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check-branch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Run PR checks
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
npm run check:pr -- --base-ref "${{ github.base_ref }}" --head-ref "${{ github.head_ref }}" \
|
||||
--allow-admin-bypass
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -1,5 +1,10 @@
|
|||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
coverage/
|
||||
dist/
|
||||
docs/api/
|
||||
docs/api-md/
|
||||
node_modules/
|
||||
!scripts/dist/
|
||||
!scripts/dist/**
|
||||
.github/repo-*.json
|
||||
144
AGENTS.md
144
AGENTS.md
|
|
@ -1,111 +1,83 @@
|
|||
# AGENT PROFILE
|
||||
|
||||
You are an expert developer focused on:
|
||||
You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub Actions.
|
||||
|
||||
- TypeScript
|
||||
- Bash
|
||||
- DevOps and CI/CD
|
||||
- Git and GitHub workflows/actions
|
||||
## Read In Order
|
||||
|
||||
## Progressive Discovery (Read In Order)
|
||||
1. `Section 0` for always-on rules.
|
||||
2. `Section 1` for code changes.
|
||||
3. `Section 2` for tests.
|
||||
4. `Section 3` for CI/CD and release work.
|
||||
5. `Section 4` for repo-specific paths and commands.
|
||||
|
||||
## Instruction Efficiency (For AGENTS.md Updates)
|
||||
## Instruction Efficiency
|
||||
|
||||
- Keep Section 0 short and mandatory-only; move situational detail to later sections.
|
||||
- Use one rule per line: trigger + action + stop condition when possible.
|
||||
- Prefer explicit keywords for high-priority rules: MUST, NEVER, ASK FIRST.
|
||||
- Use emphasis sparingly; over-highlighting reduces salience.
|
||||
- Avoid restating model capabilities; only include constraints that change behavior.
|
||||
- Prefer links to external standards instead of copying long guidance text.
|
||||
- Keep examples minimal and only where ambiguity is likely.
|
||||
- Remove duplicated or stale directives during each AGENTS.md update.
|
||||
- Keep "Always Apply" stable; put fast-changing guidance in scoped sections.
|
||||
- Optimize for lowest-capability models: simple wording, short bullets, deterministic instructions.
|
||||
- Keep core rules short and mandatory.
|
||||
- Use one rule per line.
|
||||
- Prefer MUST, NEVER, and ASK FIRST for high-priority rules.
|
||||
- Keep examples minimal.
|
||||
- Remove stale or duplicated rules when updating this file.
|
||||
- Put fast-changing details in scoped sections.
|
||||
- Optimize wording for low-context, low-capability agents.
|
||||
|
||||
### 0) Always Apply (Minimal Core)
|
||||
## 0) Always Apply
|
||||
|
||||
- Be accurate, not agreeable.
|
||||
- Push back on unsafe, incorrect, or non-compliant requests.
|
||||
- Ask clarifying questions for ambiguous or risky changes.
|
||||
- Never apologize for refusing unsafe actions.
|
||||
- Prefer minimal, reversible changes.
|
||||
- Preserve existing architecture unless a change is explicitly requested.
|
||||
- Clarify before irreversible actions such as force-push, history rewrite, mass delete, permission changes, or release publishing.
|
||||
- Never claim commands, tests, or deployments succeeded unless verified.
|
||||
- Do not change public APIs or externally observable behavior unless explicitly requested.
|
||||
- Validate touched scope with the smallest relevant lint/type/test checks.
|
||||
- If the same fix fails twice, stop and report root cause with next options.
|
||||
- Never expose, request, or store secrets in model-visible channels.
|
||||
- Keep CI/workflow/runtime permissions at least privilege.
|
||||
- Always pin GitHub Actions to full commit SHAs; never use mutable tag-only refs (for example v4) in workflows or composite actions.
|
||||
- Prefer explicit failure with actionable errors over silent fallbacks.
|
||||
- Ask clarifying questions when a change is ambiguous or risky.
|
||||
- State material assumptions before acting.
|
||||
- Prefer minimal, reversible changes.
|
||||
- Preserve existing architecture unless the user asks for a redesign.
|
||||
- Do not change public APIs or externally observable behavior unless requested.
|
||||
- Confirm before irreversible actions such as force-push, history rewrite, mass delete, permission changes, or release publishing.
|
||||
- Never claim success unless it is verified.
|
||||
- Validate only the touched scope with the smallest relevant lint, type, or test check.
|
||||
- Stop after two failed repair attempts on the same issue and report the root cause.
|
||||
- Never expose, request, or store secrets in model-visible channels.
|
||||
- Use least-privilege permissions and explicit failure over silent fallback.
|
||||
- Keep GitHub Actions pinned to full commit SHAs.
|
||||
|
||||
### 1) Apply When Writing/Changing Code
|
||||
## 1) Code Changes
|
||||
|
||||
- Keep naming consistent for semantically identical constructs.
|
||||
- Keep naming consistent for semantically equivalent constructs.
|
||||
- Follow Microsoft TypeScript style guidance.
|
||||
- For Bash, require shellcheck-clean scripts and `set -euo pipefail`.
|
||||
- Document exported types/functions and add context where behavior is not obvious.
|
||||
- Use consistent library patterns already present in the repo (`semantic-release`, `vitest`, etc.).
|
||||
- For Bash, require `shellcheck`-clean scripts and `set -euo pipefail`.
|
||||
- Document exported types and functions.
|
||||
- Add context where behavior is not obvious from the signature.
|
||||
- Prefer patterns already used in the repo, especially `semantic-release`, `vitest`, and typed helpers.
|
||||
|
||||
### 2) Apply When Writing Tests
|
||||
## 2) Tests
|
||||
|
||||
- Test naming format: `<method> <conditions> <expected>`.
|
||||
- Test behavior, not implementation details.
|
||||
- Keep setup focused on the tested condition.
|
||||
- Use constants where exact values do not matter.
|
||||
- Use parameterized tests/helpers for repeated patterns.
|
||||
- Do not add logic (if/else/loops) in test bodies.
|
||||
- If a fix fails twice, stop and explain root cause before continuing.
|
||||
- Use the naming pattern `<method> <conditions> <expected>`.
|
||||
- Test behavior, not implementation detail.
|
||||
- Keep setup tightly scoped to the condition under test.
|
||||
- Use constants when the exact value does not matter.
|
||||
- Use parameterized tests or helpers for repeated cases.
|
||||
- Do not add control flow inside test bodies.
|
||||
|
||||
### 3) Apply When Working On CI/CD
|
||||
## 3) CI/CD And Release
|
||||
|
||||
- Follow: https://github.com/github/awesome-copilot/blob/main/instructions/github-actions-ci-cd-best-practices.instructions.md
|
||||
- Abstract repeated workflow logic into reusable actions/scripts.
|
||||
- Treat `main` as production release (no known bugs).
|
||||
- Treat `staging` as experimental/integration branch.
|
||||
- Use least privilege permissions and avoid unnecessary token scopes.
|
||||
- Follow GitHub Actions CI/CD best practices: https://github.com/github/awesome-copilot/blob/main/instructions/github-actions-ci-cd-best-practices.instructions.md
|
||||
- Abstract repeated workflow logic into reusable actions or scripts.
|
||||
- Treat `main` as the stable release branch.
|
||||
- Treat `staging` as the prerelease branch.
|
||||
- Use `pr.yml` and `scripts/ops/pr_checks.mts` to enforce branch policy and workflow pinning.
|
||||
- Use `release.yml` and semantic-release for branch-based publishing.
|
||||
- Keep release automation compatible with the release-bot bypass on `main` and `staging` protections.
|
||||
|
||||
### 4) Apply For File/Script Placement
|
||||
## 4) Repo Paths And Commands
|
||||
|
||||
- Use `dev_scripts/` for development tooling and CI-support utilities.
|
||||
- Use `scripts/` for packaging/release processes.
|
||||
- Use `scripts/dev/` for development tooling.
|
||||
- Use `scripts/ops/` for repository checks and release-support automation.
|
||||
- Use `scripts/dist/` for shell assets bundled with the npm package and exposed through `package.json` bin/files entries.
|
||||
- Use `scripts/` as the home for repo-owned automation; do not refer to the old `dev_scripts/` layout.
|
||||
|
||||
## Operational Guardrails
|
||||
|
||||
### Always Do
|
||||
|
||||
- Run lint/type/test checks relevant to touched files before finalizing.
|
||||
- Warn clearly about potential backward compatibility impact.
|
||||
- Confirm with user before introducing breaking changes.
|
||||
|
||||
### Ask First
|
||||
|
||||
- Changing branch protection or release governance.
|
||||
- Adding dependencies or changing major dependency versions.
|
||||
- Modifying security-sensitive CI permissions/policies.
|
||||
|
||||
### Never Do
|
||||
|
||||
- Commit secrets, credentials, or `.env` files.
|
||||
- Duplicate workflow logic when reusable abstraction is possible.
|
||||
- Modify `node_modules/` or `vendor/`.
|
||||
- Push directly to `main`.
|
||||
|
||||
## Quick Commands (Use As Needed)
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Lint / Type
|
||||
npx eslint . --fix
|
||||
shellcheck **/*.sh
|
||||
npx tsc --noEmit
|
||||
|
||||
# Tests
|
||||
npx eslint src test scripts --ext .ts,.mts
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
npx tsc -p tsconfig.scripts.json --noEmit
|
||||
npx vitest run
|
||||
npx vitest run -t "pattern"
|
||||
|
||||
# Repo conventions
|
||||
./dev_scripts/setup.sh
|
||||
./scripts/release.sh
|
||||
npm run check:pr -- --base-ref staging --head-ref feature/example
|
||||
```
|
||||
|
|
|
|||
|
|
@ -48,8 +48,9 @@ For every code update:
|
|||
|
||||
- Run tests, fix failures, and iterate.
|
||||
- Avoid infinite loops and explain the reason when stopping.
|
||||
- Use `dev_scripts/` only for development tooling and CI/CD commands.
|
||||
- Use `scripts/` for release/packaging.
|
||||
- Use `scripts/dev/` only for development tooling and CI/CD commands.
|
||||
- Use `scripts/ops/` for operational automation and maintenance workflows.
|
||||
- Use `scripts/` root for release/packaging.
|
||||
- Warn about breaking changes that affect backward compatibility and confirm before proceeding.
|
||||
|
||||
### Refinement Prompts
|
||||
|
|
@ -196,7 +197,7 @@ The `Role & Behavior` section explicitly instructs the agent to **push back**and
|
|||
|
||||
### 2. Directory Separation
|
||||
|
||||
The configuration enforces a strict separation between `dev_scripts/` (local tooling) and `scripts/` (release/packaging). This aligns with your requirement to isolate development commands from production release logic.
|
||||
The configuration enforces a strict separation between `scripts/dev/` (local tooling), `scripts/ops/` (operations), and `scripts/` root (release/packaging). This aligns with your requirement to isolate development commands from production release logic.
|
||||
|
||||
### 3. Testing Rigor
|
||||
|
||||
|
|
|
|||
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Contributing
|
||||
|
||||
This document contains maintainer-oriented notes that are intentionally separated from the user-facing README.
|
||||
|
||||
## Development Environment
|
||||
|
||||
`./scripts/dev` containing all development related concerns. To get started after cloning the repository you
|
||||
can run `./scripts/dev/setup_devenv.mts run --ide [your IDE]`. This will setup some of the common settings and
|
||||
extensions typically used. You do not have to use this though.
|
||||
|
||||
To quickly establish credentials you can run `./scripts/dev/gh_auth.sh`. It is just a helper script to get you
|
||||
authenticated with GitHub.
|
||||
|
||||
```sh
|
||||
|
||||
```
|
||||
|
||||
## Workflow Concerns
|
||||
|
||||
### Quality and Security
|
||||
|
||||
Primary CI is defined in [.github/workflows/ci.yml](.github/workflows/ci.yml).
|
||||
|
||||
- Triggers: `pull_request`, nightly `schedule`, and `workflow_dispatch`.
|
||||
- This workflow is the merge gate for PRs; it does not publish releases.
|
||||
- Job groups by concern:
|
||||
- Setup and build artifact reuse via [.github/actions/setup/action.yml](.github/actions/setup/action.yml).
|
||||
- Static quality gates: lint and typecheck.
|
||||
- Test quality gates: test suite and type coverage threshold.
|
||||
- Security gates: CodeQL analysis and dependency audit (`npm audit --audit-level=high --omit=dev`).
|
||||
- Coverage: Codecov upload from `coverage/lcov.info`.
|
||||
|
||||
### Pull Request Branch Policy
|
||||
|
||||
- Branch policy is enforced by [.github/workflows/pr.yml](.github/workflows/pr.yml) and [scripts/ops/pr_checks.mts](scripts/ops/pr_checks.mts).
|
||||
- Feature branches should target `staging`.
|
||||
- `main` only accepts merges from `staging`.
|
||||
- A release-bot role has bypass permission for the `main` and `staging` branch protections.
|
||||
|
||||
Pull request policy checks are defined in [.github/workflows/pr.yml](.github/workflows/pr.yml).
|
||||
|
||||
- Triggered on PR open/update/reopen/edited events.
|
||||
- Runs `npm run check:pr` to enforce branch policy and pinned action ref validation.
|
||||
- Pull requests into `main` must come from `staging`.
|
||||
- Treat short-lived task branches as disposable and prefer deleting them after merge.
|
||||
|
||||
### Branch Sync and Merge Policy
|
||||
|
||||
Branch ancestry enforcement is defined in [.github/workflows/branch-sync.yml](.github/workflows/branch-sync.yml).
|
||||
|
||||
- Triggered on pushes to `main`, on a daily schedule, and manually via `workflow_dispatch`.
|
||||
- Verifies that `staging` remains an ancestor of `main`.
|
||||
- `staging` into `main` should preserve ancestry with a merge commit; squash and rebase merges break this guardrail.
|
||||
- If `staging` ancestry is broken, repair it immediately with a merge-commit-based sync before taking further releases.
|
||||
|
||||
### Release and Publishing
|
||||
|
||||
Release automation is defined in [.github/workflows/release.yml](.github/workflows/release.yml) and uses semantic-release.
|
||||
|
||||
- Triggers: push to `main` or `staging`, plus `workflow_dispatch`.
|
||||
- `staging` produces prereleases on the `next` channel with `rc` suffixes.
|
||||
- `main` produces stable releases and deploys API docs to GitHub Pages.
|
||||
- semantic-release updates package metadata as part of its own prepare/commit flow.
|
||||
- The release-bot GitHub App token is used so the release job can publish, tag, and push release commits back to the branch that triggered it.
|
||||
|
||||
Release configuration is in [release.config.ts](release.config.ts).
|
||||
|
||||
## Conventional Commits
|
||||
|
||||
semantic-release determines version bump level from commit messages.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
git commit -m "fix(parser): handle empty package output"
|
||||
git commit -m "feat(manager): add availability check"
|
||||
git commit -m "feat!: remove deprecated API"
|
||||
```
|
||||
|
||||
## Repository Scripts
|
||||
|
||||
Repository scripts are grouped under [scripts/dev](scripts/dev) and [scripts/ops](scripts/ops).
|
||||
|
||||
- [check_latest_action_pin.mts](scripts/ops/check_latest_action_pin.mts): checks whether pinned GitHub Action refs are up to date against upstream releases.
|
||||
- [check_release_tag.mts](scripts/ops/check_release_tag.mts): validates release tag format and consistency with `package.json` version.
|
||||
- [create_testcase_logs.mts](scripts/dev/create_testcase_logs.mts): regenerates command execution logs used by integration/parser fixtures.
|
||||
- [hotfix_pr.mts](scripts/dev/hotfix_pr.mts): automates hotfix branch creation and PR creation/update flow.
|
||||
- [gh_sync.mts](scripts/dev/gh_sync.mts): synchronizes repository settings, rulesets, variables, and tags JSON. **These are not for source control** since they leak information.
|
||||
- [setup_devenv.mts](scripts/dev/setup_devenv.mts): bootstraps local developer dependencies, workspace settings, and npm audit remediation.
|
||||
- [node_ver.mts](scripts/dev/node_ver.mts): verifies or updates Node version alignment across repo files and local Node installation.
|
||||
|
||||
## Integration Test Notes
|
||||
|
||||
Integration test suite is [test/ubuntu.integration.test.ts](test/ubuntu.integration.test.ts).
|
||||
|
||||
- These tests execute real APT commands and are environment-sensitive.
|
||||
- They are intentionally narrower than unit tests to keep runtime manageable.
|
||||
- Mutating package operations should use locking for safety when applicable.
|
||||
|
||||
## Docs and Publishing
|
||||
|
||||
- API docs are generated into [docs/api](docs/api) and are generated during the release process, not
|
||||
committed to source.
|
||||
- Release workflow publishes package artifacts and deploys docs via GitHub Pages.
|
||||
|
||||
## Operational Concerns
|
||||
|
||||
- Node.js baseline is managed in [package.json](package.json) engines.
|
||||
- Keep `package-lock.json` committed and synchronized with dependency changes.
|
||||
- Prefer updating workflow action SHAs with care and validate in CI.
|
||||
- Prefer full commit SHA pinning for all third-party GitHub Actions.
|
||||
- Repository settings, rulesets, variables, and tags are synchronized via `scripts/dev/gh_sync.mts`.
|
||||
- Repository settings are tracked in [.github/repo-settings.json](.github/repo-settings.json) and can be applied with `npm run repo:settings:upload`.
|
||||
- Repository rulesets are tracked in [.github/repo-rulesets.json](.github/repo-rulesets.json) and can be applied with `npm run repo:rulesets:upload`.
|
||||
- Repository variables are tracked in `.github/repo-vars.json` and can be applied with `npm run repo:vars:upload`.
|
||||
- Repository tags are tracked in `.github/repo-tags.json` and can be applied with `npm run repo:tags:upload`.
|
||||
- Use `npm run repo:all:download` and `npm run repo:all:upload` to synchronize every tracked GitHub metadata target in one pass.
|
||||
- Prefer explicit status checks and branch protections over informal merge discipline.
|
||||
- Keep release automation branch-based and semantic-release driven.
|
||||
23
README.md
23
README.md
|
|
@ -1,23 +1,23 @@
|
|||
# cache-apt-pkgs-action
|
||||
<img src="logo.png" alt="Description" height="80" align="center" style="padding-right: 1em;"> <span style="font-size: 2em; font-weight: bold;">Cache APT Packages Action v2</span>
|
||||
|
||||
[](https://github.com/awalsh128/fluentcpp/blob/master/LICENSE)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action-ci/actions/workflows/master_test.yml)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action-ci/actions/workflows/dev_test.yml)
|
||||

|
||||

|
||||
[](https://codecov.io/gh/awalsh128/cache-apt-pkgs-action)
|
||||
|
||||
This action allows caching of Advanced Package Tool (APT) package dependencies to improve workflow execution time instead of installing the packages on every run.
|
||||
|
||||
> [!NOTE]
|
||||
> The open source projects that I maintain are a labor of love. If you find this useful and want to support open source, **please consider donating and [Buy Me a Coffe](http://buymeacoffee.com/awalsh128)**.
|
||||
For more information on changes in `v2` see the [Version 2 FAQ](V2_FAQ.MD)
|
||||
|
||||
> [!TIP]
|
||||
> If you find this project useful, please consider supporting it [as a sponsor and show some ❤️ for open source maintainers](https://github.com/sponsors/awalsh128).
|
||||
|
||||
> [!NOTE]
|
||||
> Version 2 of the action is now available! See [Version 2 FAQ](V2_FAQ.MD) for more information.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Looking for co-maintainers to help review changes, and investigate issues. I haven't had as much time to stay on top of this action as I would like to and want to make sure it is still responsive and reliable for the community. If you are interested, please reach out.
|
||||
|
||||
## Documentation
|
||||
|
||||
This action is a composition of [actions/cache](https://github.com/actions/cache/) and the `apt` utility. Some actions require additional APT based packages to be installed in order for other steps to be executed. Packages can be installed when ran but can consume much of the execution workflow time.
|
||||
This action is a composition of [actions/cache](https://github.com/actions/cache/) and the [ts-apt](http://github.com/awalsh128/ts-apt) library. Some actions require additional APT based packages to be installed in order for other steps to be executed. Packages can be installed when ran but can consume much of the execution workflow time.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -32,9 +32,8 @@ There are three kinds of version labels you can use.
|
|||
- `@latest` - This will give you the latest release.
|
||||
- `@v#` - Major only will give you the latest release for that major version only (e.g. `v1`).
|
||||
- Branch
|
||||
- `@master` - Most recent manual and automated tested code. Possibly unstable since it is pre-release.
|
||||
- `@staging` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code.
|
||||
- `@dev` - Very unstable and contains experimental features. Automated testing may not show breaks since CI is also updated based on code in dev.
|
||||
- `@main-v2` - Most recent manual and automated tested code. Possibly unstable since it is pre-release.
|
||||
- `@staging-v2` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code.
|
||||
|
||||
### Inputs
|
||||
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { ROOT_DIR, fail, logInfo, logSuccess } from "./lib.mjs";
|
||||
|
||||
function toLog(result) {
|
||||
return `*** CMD_LINE ${result.cmdLine}\n*** EXIT_CODE ${result.exitCode}\n*** STDOUT\n${result.stdout}*** STDERR\n${result.stderr}`;
|
||||
}
|
||||
|
||||
async function execute(cmdLine) {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const parts = cmdLine.trim().split(/\s+/);
|
||||
const command = parts[0]; // "apt-get"
|
||||
const args = parts.slice(1);
|
||||
|
||||
const child = spawn("sudo", [command, ...args], {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${process.env.PATH}:/usr/bin`,
|
||||
DEBIAN_FRONTEND: "noninteractive",
|
||||
DEBCONF_NONINTERACTIVE_SEEN: "true",
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
rejectPromise(error);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`Command '${cmdLine}' failed with exit code ${code ?? "unknown"}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise({
|
||||
cmdLine,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeLogFile(baseDir, relativePath, result) {
|
||||
const outputPath = resolve(baseDir, `${relativePath}.log`);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, toLog(result), "utf8");
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const testDataDir = resolve(ROOT_DIR, "test/data");
|
||||
let written = 0;
|
||||
|
||||
for (const entry of testLogFiles) {
|
||||
if (entry.preExecCmdLine) {
|
||||
for (const cmd of entry.preExecCmdLine) {
|
||||
await execute(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await execute(entry.execCmdLine);
|
||||
const outputPath = await writeLogFile(testDataDir, entry.filepath, result);
|
||||
written += 1;
|
||||
logInfo(`Wrote ${outputPath}`);
|
||||
}
|
||||
|
||||
await execute("apt-get upgrade -y");
|
||||
await execute("apt-get autoremove -y");
|
||||
|
||||
logSuccess(`Wrote ${written} command execution log(s).`);
|
||||
}
|
||||
|
||||
const testLogFiles = [
|
||||
{
|
||||
filepath: "autoclean",
|
||||
execCmdLine: "apt-get autoclean",
|
||||
},
|
||||
{
|
||||
filepath: "autoremove_found",
|
||||
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y xdot"],
|
||||
execCmdLine: "apt-get autoremove -y",
|
||||
},
|
||||
{
|
||||
filepath: "autoremove_notfound",
|
||||
preExecCmdLine: ["apt-get autoremove -y"],
|
||||
execCmdLine: "apt-get autoremove -y",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_mixedfoundnotfound",
|
||||
execCmdLine: "apt-cache show xdot python3 nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_multiplefound",
|
||||
execCmdLine: "apt-cache show xdot python3",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_singlefound",
|
||||
execCmdLine: "apt-cache show python3",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_singlenotfound",
|
||||
execCmdLine: "apt-cache show nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "install_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine: "apt-get install -y xdot nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "install_mixedinstallstatus",
|
||||
preExecCmdLine: [
|
||||
"apt-get install -y xdot",
|
||||
"apt-get remove -y rolldice",
|
||||
"apt-get autoremove -y",
|
||||
],
|
||||
execCmdLine: "apt-get install -y rolldice xdot",
|
||||
},
|
||||
{
|
||||
filepath: "install_singleinstalled",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine: "apt-get install -y xdot",
|
||||
},
|
||||
{
|
||||
filepath: "install_singlenotfound",
|
||||
execCmdLine: "apt-get install -y nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "install_singlenotinstalled",
|
||||
preExecCmdLine: ["apt-get remove -y xdot", "apt-get autoremove -y"],
|
||||
execCmdLine: "apt-get install -y xdot",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalled",
|
||||
execCmdLine: "dpkg-query -W -f ${binary:Package}=${Version}\\n",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalledfiles_found",
|
||||
execCmdLine: "dpkg-query -L xdot",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalledfiles_notfound",
|
||||
execCmdLine: "dpkg-query -L nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "listupgradable_found",
|
||||
preExecCmdLine: [
|
||||
"apt-get remove -y firefox-locale-en",
|
||||
"apt-get install -y firefox-locale-en=75.0+build3-0ubuntu1",
|
||||
"apt-get remove -y uuid-dev",
|
||||
"apt-get install -y uuid-dev=2.34-0.1ubuntu9",
|
||||
],
|
||||
execCmdLine: "apt list --upgradeable",
|
||||
},
|
||||
{
|
||||
filepath: "listupgradable_notfound",
|
||||
preExecCmdLine: ["apt-get upgrade -y"],
|
||||
execCmdLine: "apt list --upgradeable",
|
||||
},
|
||||
{
|
||||
filepath: "remove_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine: "apt-get remove -y xdot nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "remove_mixedinstallstatus",
|
||||
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y rolldice"],
|
||||
execCmdLine: "apt-get remove -y rolldice xdot",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singlenotinstalled",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine: "apt-get remove -y xdot",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singlenotfound",
|
||||
execCmdLine: "apt-get remove -y nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singleinstalled",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine: "apt-get remove -y xdot",
|
||||
},
|
||||
{
|
||||
filepath: "search_multiplefound",
|
||||
execCmdLine: "apt search vim",
|
||||
},
|
||||
{
|
||||
filepath: "search_nonefound",
|
||||
execCmdLine: "apt search nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "search_singlefound",
|
||||
execCmdLine: "apt search vim-vimerl-syntax",
|
||||
},
|
||||
{
|
||||
filepath: "search_namesonlysinglefound",
|
||||
execCmdLine: "apt search --names-only bash",
|
||||
},
|
||||
{
|
||||
filepath: "update",
|
||||
execCmdLine: "apt-get update -y",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine: "apt-get upgrade -y xdot nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_mixedupgradestatus",
|
||||
preExecCmdLine: [
|
||||
"apt-get install -y xdot",
|
||||
"apt-get remove -y xxd",
|
||||
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
|
||||
],
|
||||
execCmdLine: "apt-get upgrade -y xxd xdot",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singleupgraded",
|
||||
preExecCmdLine: ["apt-get upgrade -y xdot"],
|
||||
execCmdLine: "apt-get upgrade -y xdot",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singlenotfound",
|
||||
execCmdLine: "apt-get upgrade -y nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singlenotupgraded",
|
||||
preExecCmdLine: [
|
||||
"apt-get remove -y xxd",
|
||||
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
|
||||
],
|
||||
execCmdLine: "apt-get upgrade -y xxd",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singleupgraded",
|
||||
preExecCmdLine: ["apt-get install xdot"],
|
||||
execCmdLine: "apt-get upgrade xdot",
|
||||
},
|
||||
];
|
||||
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
import {
|
||||
ROOT_DIR,
|
||||
assertNonEmpty,
|
||||
confirmPrompt,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
run,
|
||||
runCaptureOutput,
|
||||
tryRun,
|
||||
usage,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const issueId = process.argv[2] ?? "";
|
||||
const base = process.argv[3] ?? "";
|
||||
const usageMessage = usage(process.argv[1], "<issue ID> <target branch>");
|
||||
|
||||
assertNonEmpty(issueId, "Issue ID is empty", usageMessage);
|
||||
assertNonEmpty(base, "Target branch is empty", usageMessage);
|
||||
|
||||
if (!/^\d+$/.test(issueId)) {
|
||||
fail("Issue ID must be an integer.");
|
||||
}
|
||||
|
||||
const branchSuffix = `issue-${issueId}`;
|
||||
const hotfixBranch = `hotfix/${branchSuffix}`;
|
||||
|
||||
function branchExistsOnOrigin(branch) {
|
||||
return tryRun("git", [
|
||||
"ls-remote",
|
||||
"--exit-code",
|
||||
"--heads",
|
||||
"origin",
|
||||
branch,
|
||||
]).ok;
|
||||
}
|
||||
|
||||
function createOrCheckoutBranch(checkoutBranch, baseBranch) {
|
||||
if (branchExistsOnOrigin(checkoutBranch)) {
|
||||
logInfo(
|
||||
`Branch ${checkoutBranch} exists. Checking out and merging ${baseBranch}...`,
|
||||
);
|
||||
run("git", ["checkout", checkoutBranch]);
|
||||
run("git", ["pull", "origin", checkoutBranch]);
|
||||
run("git", ["merge", baseBranch]);
|
||||
} else {
|
||||
logInfo(`Creating hotfix branch for issue ${issueId}...`);
|
||||
run("git", ["checkout", baseBranch]);
|
||||
run("git", ["checkout", "-b", checkoutBranch]);
|
||||
run("git", ["merge", baseBranch]);
|
||||
}
|
||||
}
|
||||
|
||||
function commitIfNeeded(message) {
|
||||
run("git", ["add", "."]);
|
||||
const commit = tryRun("git", ["commit", "-m", message]);
|
||||
if (commit.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const combined = `${commit.stdout}\n${commit.stderr}`;
|
||||
if (/nothing to commit|no changes added to commit/i.test(combined)) {
|
||||
logSuccess("No local changes to commit.");
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(commit.stderr);
|
||||
process.exit(commit.status ?? 1);
|
||||
}
|
||||
|
||||
function pushChanges(fixType, syncBranch, syncBase) {
|
||||
const message = `${fixType}: resolve critical production issue in #${issueId}`;
|
||||
commitIfNeeded(message);
|
||||
|
||||
const prUrl = runCaptureOutput("gh", [
|
||||
"pr",
|
||||
"list",
|
||||
"--head",
|
||||
syncBranch,
|
||||
"--base",
|
||||
syncBase,
|
||||
"--state",
|
||||
"open",
|
||||
"--json",
|
||||
"url",
|
||||
"--jq",
|
||||
".[0].url",
|
||||
]);
|
||||
|
||||
if (prUrl) {
|
||||
logSuccess(`PR already exists: ${prUrl}`);
|
||||
} else {
|
||||
logInfo("No PR found. Creating new PR...");
|
||||
run("gh", [
|
||||
"pr",
|
||||
"create",
|
||||
"--head",
|
||||
syncBranch,
|
||||
"--base",
|
||||
syncBase,
|
||||
"--title",
|
||||
message,
|
||||
]);
|
||||
}
|
||||
|
||||
logInfo(`Pushing changes from ${syncBase} to ${syncBranch}...`);
|
||||
run("git", ["push", "origin", syncBranch]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
createOrCheckoutBranch(hotfixBranch, base);
|
||||
|
||||
await confirmPrompt(
|
||||
"Edit files and confirm to continue. This can always be rerun to pickup where you left off. Continue?",
|
||||
);
|
||||
|
||||
pushChanges("fix", hotfixBranch, base);
|
||||
|
||||
const syncBranch = `sync/staging-${branchSuffix}`;
|
||||
createOrCheckoutBranch(syncBranch, hotfixBranch);
|
||||
pushChanges("sync", syncBranch, "staging");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export { fileURLToPath, path, process };
|
||||
|
||||
export class Paths {
|
||||
constructor(importMetaUrl) {
|
||||
this.importMetaUrl = importMetaUrl;
|
||||
}
|
||||
|
||||
scriptDir() {
|
||||
return path.dirname(fileURLToPath(this.importMetaUrl));
|
||||
}
|
||||
|
||||
repoRootDir() {
|
||||
return path.resolve(this.scriptDir(), "..");
|
||||
}
|
||||
}
|
||||
|
||||
export const ROOT_DIR = new Paths(import.meta.url).repoRootDir();
|
||||
|
||||
export const REPO_OWNER = "awalsh128";
|
||||
export const REPO_NAME = "cache-apt-pkgs-action";
|
||||
export const REPO_SLUG = `${REPO_OWNER}/${REPO_NAME}`;
|
||||
export const REPO_URL = `https://github.com/${REPO_SLUG}.git`;
|
||||
export const GITHUB_API_BASE_URL = "https://api.github.com";
|
||||
export const GITHUB_USER_AGENT = `${REPO_NAME}-dev-scripts`;
|
||||
|
||||
export const VSCODE_SETTINGS_RELPATH = ".vscode/settings.json";
|
||||
export const VSCODE_SETTINGS_DEFAULTS = {
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"*": true,
|
||||
},
|
||||
"chat.useAgentsMdFile": true,
|
||||
};
|
||||
|
||||
export const CURRENT_NODE_VERSION = "24";
|
||||
|
||||
export function usage(scriptPath, params) {
|
||||
return `usage: ${path.basename(scriptPath)} ${params}`;
|
||||
}
|
||||
|
||||
export function fail(message, exitCode = 1) {
|
||||
logError(message);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
export function logInfo(message) {
|
||||
console.log(`ℹ️ ${message}`);
|
||||
}
|
||||
|
||||
export function logSuccess(message) {
|
||||
console.log(`✅ ${message}`);
|
||||
}
|
||||
|
||||
export function logWarn(message) {
|
||||
console.log(`⚠️ ${message}`);
|
||||
}
|
||||
|
||||
export function logError(message) {
|
||||
console.error(`❌ ${message}`);
|
||||
}
|
||||
|
||||
export function assertNonEmpty(value, message, usageMessage) {
|
||||
if (!value || value.trim().length === 0) {
|
||||
if (usageMessage) {
|
||||
console.error(usageMessage);
|
||||
}
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command) {
|
||||
const isExecutable = (candidatePath) => {
|
||||
try {
|
||||
accessSync(candidatePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (command.includes("/")) {
|
||||
return isExecutable(command);
|
||||
}
|
||||
|
||||
const pathValue = process.env.PATH ?? "";
|
||||
const dirs = pathValue.split(path.delimiter).filter(Boolean);
|
||||
return dirs.some((dirPath) => isExecutable(path.join(dirPath, command)));
|
||||
}
|
||||
|
||||
export function ensureDirExists(dirPath) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
export function readJsonFile(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
export function writeJsonFile(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function readNodeMajorVersion(rootDir = ROOT_DIR) {
|
||||
const nodeVersionPath = path.join(rootDir, ".node_ver");
|
||||
|
||||
let fileText;
|
||||
try {
|
||||
fileText = readFileSync(nodeVersionPath, "utf8");
|
||||
} catch (error) {
|
||||
fail(
|
||||
`Missing or unreadable version file at ${nodeVersionPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines = fileText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (lines.length !== 1) {
|
||||
fail(".node_ver must contain exactly one non-empty line.");
|
||||
}
|
||||
|
||||
const rawVersion = lines[0];
|
||||
if (!/^\d+$/.test(rawVersion)) {
|
||||
fail(
|
||||
".node_ver must contain only the Node.js major version (e.g. 20, 24).",
|
||||
);
|
||||
}
|
||||
|
||||
return rawVersion;
|
||||
}
|
||||
|
||||
function normalizeRunOptions(optionsOrQuiet) {
|
||||
if (typeof optionsOrQuiet === "boolean") {
|
||||
return {
|
||||
cwd: ROOT_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: optionsOrQuiet ? "pipe" : "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
const options = optionsOrQuiet ?? {};
|
||||
return {
|
||||
cwd: options.cwd ?? ROOT_DIR,
|
||||
env: options.env ?? process.env,
|
||||
encoding: options.encoding ?? "utf8",
|
||||
stdio:
|
||||
options.stdio ?? (options.stdout || options.stderr ? "pipe" : "inherit"),
|
||||
};
|
||||
}
|
||||
|
||||
export function run(command, args = [], optionsOrQuiet = {}) {
|
||||
const options = normalizeRunOptions(optionsOrQuiet);
|
||||
try {
|
||||
return execFileSync(command, args, options);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to execute '${command} ${args.join(" ")}'. ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function tryRun(command, args = [], optionsOrQuiet = {}) {
|
||||
const options = normalizeRunOptions(optionsOrQuiet);
|
||||
const result = spawnSync(command, args, options);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function runCaptureOutput(command, args = [], options = {}) {
|
||||
return run(command, args, {
|
||||
...options,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export async function confirmPrompt(
|
||||
message,
|
||||
yesPattern = /^[yY]$/,
|
||||
noPattern = /^[nN]$/,
|
||||
) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
const getText = (pattern) =>
|
||||
pattern.source
|
||||
.trim()
|
||||
.replace(/^\^/, "")
|
||||
.replace(/\$$/, "")
|
||||
.replace(/\\([^\\])/g, "$1");
|
||||
while (true) {
|
||||
const answer = await rl
|
||||
.question(
|
||||
`${message} [${getText(yesPattern)} | ${getText(noPattern)}] `,
|
||||
)
|
||||
.then((ans) => ans.trim().toLowerCase());
|
||||
if (yesPattern.test(answer) || answer.trim() === "") {
|
||||
return;
|
||||
}
|
||||
if (noPattern.test(answer)) {
|
||||
fail("Aborted.", 0);
|
||||
}
|
||||
logError(
|
||||
`Invalid option '${answer}' selected. Options are: ${getText(yesPattern)} or ${getText(noPattern)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
ROOT_DIR,
|
||||
commandExists,
|
||||
ensureDirExists,
|
||||
fail,
|
||||
logError,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
readNodeMajorVersion,
|
||||
readJsonFile,
|
||||
run,
|
||||
tryRun,
|
||||
VSCODE_SETTINGS_DEFAULTS,
|
||||
VSCODE_SETTINGS_RELPATH,
|
||||
writeJsonFile,
|
||||
} from "./lib.mjs";
|
||||
|
||||
function ensureVscodeSettingsFile() {
|
||||
const vscodeDir = `${ROOT_DIR}/.vscode`;
|
||||
const vscodeSettingsPath = `${ROOT_DIR}/${VSCODE_SETTINGS_RELPATH}`;
|
||||
|
||||
ensureDirExists(vscodeDir);
|
||||
|
||||
if (!existsSync(vscodeSettingsPath)) {
|
||||
writeJsonFile(vscodeSettingsPath, VSCODE_SETTINGS_DEFAULTS);
|
||||
logWarn(`Created missing ${vscodeSettingsPath}`);
|
||||
}
|
||||
|
||||
return vscodeSettingsPath;
|
||||
}
|
||||
|
||||
function loadVscodeSettingsJson() {
|
||||
const vscodeSettingsPath = ensureVscodeSettingsFile();
|
||||
return {
|
||||
vscodeSettingsPath,
|
||||
vscodeSettingsJson: readJsonFile(vscodeSettingsPath),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureAgentsFilesUsed() {
|
||||
const { vscodeSettingsPath, vscodeSettingsJson } = loadVscodeSettingsJson();
|
||||
|
||||
if (!vscodeSettingsJson["chat.useAgentsMdFile"]) {
|
||||
vscodeSettingsJson["chat.useAgentsMdFile"] = true;
|
||||
writeJsonFile(vscodeSettingsPath, vscodeSettingsJson);
|
||||
logWarn(
|
||||
`Updated ${vscodeSettingsPath} to enable chat.useAgentsMdFile for AGENTS.md support.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAptDependencies() {
|
||||
if (!commandExists("apt-get")) {
|
||||
fail("apt-get is required for system dependency installation.");
|
||||
}
|
||||
|
||||
const aptPackages = [
|
||||
["git", "git"],
|
||||
["gh", "gh"],
|
||||
["jq", "jq"],
|
||||
["curl", "curl"],
|
||||
["shellcheck", "shellcheck"],
|
||||
["flock", "util-linux"],
|
||||
];
|
||||
|
||||
const missingPackages = [
|
||||
...new Set(
|
||||
aptPackages
|
||||
.filter(([command]) => !commandExists(command))
|
||||
.map(([, packageName]) => packageName),
|
||||
),
|
||||
];
|
||||
|
||||
if (missingPackages.length === 0) {
|
||||
logSuccess("All required apt CLI dependencies are already installed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const aptRunner = commandExists("sudo")
|
||||
? (args) => run("sudo", ["apt-get", ...args])
|
||||
: (args) => run("apt-get", args);
|
||||
|
||||
logInfo(`Installing missing apt packages: ${missingPackages.join(" ")}`);
|
||||
aptRunner(["update"]);
|
||||
aptRunner(["install", "-y", ...missingPackages]);
|
||||
}
|
||||
|
||||
function ensureNodeDependencies(nodeVersionMajor) {
|
||||
if (!commandExists("npm")) {
|
||||
fail(
|
||||
`npm is required but not installed. Install Node.js >=${nodeVersionMajor} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fast deterministic install path aligned with lockfile-based CI behavior.
|
||||
logInfo(
|
||||
"Installing npm dependencies (including devDependencies) via npm ci...",
|
||||
);
|
||||
run("npm", ["ci", "--include=dev"]);
|
||||
}
|
||||
|
||||
function parseAuditJson(outputText) {
|
||||
if (!outputText || outputText.trim().length === 0) {
|
||||
fail("npm audit produced empty output.");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(outputText);
|
||||
} catch {
|
||||
const firstBraceIndex = outputText.indexOf("{");
|
||||
const lastBraceIndex = outputText.lastIndexOf("}");
|
||||
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
||||
return JSON.parse(outputText.slice(firstBraceIndex, lastBraceIndex + 1));
|
||||
}
|
||||
fail("Unable to parse npm audit JSON output.");
|
||||
}
|
||||
}
|
||||
|
||||
function getAuditCounts() {
|
||||
const audit = tryRun("npm", ["audit", "--json"], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const report = parseAuditJson(audit.stdout ?? "");
|
||||
const vulnerabilities = report?.metadata?.vulnerabilities ?? {};
|
||||
|
||||
return {
|
||||
critical: Number(vulnerabilities.critical ?? 0),
|
||||
high: Number(vulnerabilities.high ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function runAuditFix(force = false) {
|
||||
const args = force ? ["audit", "fix", "--force"] : ["audit", "fix"];
|
||||
const result = tryRun("npm", args, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
logWarn(
|
||||
`npm ${args.join(" ")} exited with code ${result.status ?? "unknown"}. Continuing to verification...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAuditRemediation() {
|
||||
let counts = getAuditCounts();
|
||||
logInfo(`Audit baseline: high=${counts.high}, critical=${counts.critical}.`);
|
||||
|
||||
if (counts.high === 0 && counts.critical === 0) {
|
||||
logSuccess("No high/critical npm vulnerabilities detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`Detected vulnerabilities: high=${counts.high}, critical=${counts.critical}. Running npm audit fix...`,
|
||||
);
|
||||
runAuditFix(false);
|
||||
counts = getAuditCounts();
|
||||
logInfo(
|
||||
`Audit after npm audit fix: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
|
||||
if (counts.high === 0 && counts.critical === 0) {
|
||||
logSuccess(
|
||||
"High/critical npm vulnerabilities remediated with npm audit fix.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`High/critical vulnerabilities remain: high=${counts.high}, critical=${counts.critical}. Running npm audit fix --force...`,
|
||||
);
|
||||
runAuditFix(true);
|
||||
counts = getAuditCounts();
|
||||
logInfo(
|
||||
`Audit after npm audit fix --force: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
|
||||
if (counts.high > 0 || counts.critical > 0) {
|
||||
fail(
|
||||
`Unable to remediate all high/critical vulnerabilities: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
}
|
||||
|
||||
logSuccess("High/critical npm vulnerabilities remediated.");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const nodeVersionMajor = readNodeMajorVersion();
|
||||
logInfo("Ensuring .vscode settings file present...");
|
||||
ensureVscodeSettingsFile();
|
||||
logInfo("Ensuring chat.useAgentsMdFile is enabled...");
|
||||
ensureAgentsFilesUsed();
|
||||
logInfo("Ensuring required APT dependencies are installed...");
|
||||
ensureAptDependencies();
|
||||
logInfo(
|
||||
`Setting up development environment for Node.js v${nodeVersionMajor}.x...`,
|
||||
);
|
||||
ensureNodeDependencies(nodeVersionMajor);
|
||||
ensureAuditRemediation();
|
||||
logSuccess("Development environment setup complete.");
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1090
|
||||
source "${SCRIPT_DIR}/lib.sh"
|
||||
|
||||
PACKAGE_PATH="${SCRIPT_DIR}/../package.json"
|
||||
LOCAL_VAL="file:../ts-apt"
|
||||
NPM_VAL="^$(npm view ts-apt version 2> /dev/null | tr -d '\n')"
|
||||
|
||||
function is_local_switched() {
|
||||
grep -q "\"ts-apt\": \"${LOCAL_VAL}\"," "${PACKAGE_PATH}"
|
||||
}
|
||||
|
||||
function switch() {
|
||||
if ! is_local_switched; then
|
||||
echo "Switching ts-apt dependency to local path '${LOCAL_VAL}'..."
|
||||
if [[ ! -d "${SCRIPT_DIR}/../../ts-apt" ]]; then
|
||||
echo "Local ts-apt path '${LOCAL_VAL}' does not exist. Checking out..."
|
||||
clone_repo "ts-apt" "${SCRIPT_DIR}/../../ts-apt"
|
||||
fi
|
||||
sed -i "s#\"ts-apt\": \".*\",#\"ts-apt\": \"${LOCAL_VAL}\",#" "${PACKAGE_PATH}"
|
||||
echo "Switched to local ts-apt. Run 'npm install --ignore-scripts' to refresh node_modules and package-lock.json."
|
||||
else
|
||||
echo "Switching ts-apt dependency to NPM version '${NPM_VAL}'..."
|
||||
sed -i "s#\"ts-apt\": \".*\",#\"ts-apt\": \"${NPM_VAL}\",#" "${PACKAGE_PATH}"
|
||||
echo "Switched to published ts-apt. Run 'npm install --ignore-scripts' to refresh node_modules and package-lock.json."
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$1" == "--is_local_switched" ]]; then
|
||||
is_local_switched
|
||||
else
|
||||
switch
|
||||
fi
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import {
|
||||
ROOT_DIR,
|
||||
assertNonEmpty,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
REPO_SLUG,
|
||||
run,
|
||||
runCaptureOutput,
|
||||
tryRun,
|
||||
usage,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const target = process.argv[2] ?? "";
|
||||
const confirmFlag = process.argv[3] ?? "";
|
||||
const usageMessage = usage(
|
||||
process.argv[1],
|
||||
"<target branch> --confirm-destructive",
|
||||
);
|
||||
|
||||
assertNonEmpty(target, "Target branch is empty", usageMessage);
|
||||
|
||||
if (confirmFlag !== "--confirm-destructive") {
|
||||
fail(
|
||||
"WARNING: This operation force-rewrites history and has irreversible side effects.\n" +
|
||||
"To proceed, re-run the command with the --confirm-destructive flag.",
|
||||
);
|
||||
}
|
||||
|
||||
const rootDir = ROOT_DIR;
|
||||
const timestamp = new Date().toISOString().replace(/[:T]/g, "_").slice(0, 19);
|
||||
function ensureRepo() {
|
||||
logInfo(`Ensuring ${rootDir} is a git repository...`);
|
||||
if (
|
||||
!tryRun("git", ["rev-parse", "--is-inside-work-tree"], { cwd: rootDir }).ok
|
||||
) {
|
||||
fail(`${rootDir} is not a git repository`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetRef(branch) {
|
||||
if (
|
||||
tryRun("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd: rootDir,
|
||||
}).ok
|
||||
) {
|
||||
logInfo(`Target branch ${branch} exists locally.`);
|
||||
return branch;
|
||||
}
|
||||
|
||||
if (
|
||||
tryRun(
|
||||
"git",
|
||||
["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`],
|
||||
{ cwd: rootDir },
|
||||
).ok
|
||||
) {
|
||||
return `origin/${branch}`;
|
||||
}
|
||||
|
||||
fail(`Target branch ${branch} does not exist locally or on origin`);
|
||||
}
|
||||
|
||||
function listGitHubRunIds() {
|
||||
const output = runCaptureOutput(
|
||||
"gh",
|
||||
["run", "list", "--limit", "1000", "--json", "databaseId"],
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
|
||||
if (!output) {
|
||||
logWarn("No GitHub workflow runs found.");
|
||||
return [];
|
||||
}
|
||||
|
||||
let runs;
|
||||
try {
|
||||
runs = JSON.parse(output);
|
||||
logInfo(`Found ${runs.length} GitHub workflow run(s) to delete.\n`);
|
||||
} catch (error) {
|
||||
fail(
|
||||
`Unable to parse gh run list response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(runs)) {
|
||||
logWarn("No GitHub workflow runs found.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return runs
|
||||
.map((runEntry) => String(runEntry?.databaseId ?? "").trim())
|
||||
.filter((runId) => /^\d+$/.test(runId));
|
||||
}
|
||||
|
||||
function deleteGitHubRuns() {
|
||||
const runIds = listGitHubRunIds();
|
||||
if (runIds.length === 0) {
|
||||
logInfo("No GitHub workflow runs found to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`Deleting ${runIds.length} GitHub workflow run(s)...`);
|
||||
for (const runId of runIds) {
|
||||
run("gh", ["run", "delete", runId, "--repo", REPO_SLUG], {
|
||||
cwd: rootDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureRepo();
|
||||
|
||||
const targetRef = resolveTargetRef(target);
|
||||
const backupBranch = `backup/pre-wipe-${target}-${timestamp}`;
|
||||
const backupTag = `pre-wipe-${target}-${timestamp}`;
|
||||
const mirrorDir = path.resolve(
|
||||
rootDir,
|
||||
"..",
|
||||
`cache-apt-pkgs-action.mirror-backup.${timestamp}.git`,
|
||||
);
|
||||
|
||||
logInfo(`Creating mirror backup at ${mirrorDir}...`);
|
||||
if (tryRun("test", ["-d", mirrorDir]).ok) {
|
||||
fail(`${mirrorDir} already exists`);
|
||||
}
|
||||
|
||||
logInfo(`Cloning ${rootDir} to mirror backup...`);
|
||||
run("git", ["clone", "--mirror", ".", mirrorDir], { cwd: rootDir });
|
||||
|
||||
logInfo(`Creating backup refs from ${targetRef}...`);
|
||||
run("git", ["branch", backupBranch, targetRef], { cwd: rootDir });
|
||||
run(
|
||||
"git",
|
||||
[
|
||||
"tag",
|
||||
"-a",
|
||||
backupTag,
|
||||
"-m",
|
||||
`Backup before history rewrite of ${target}`,
|
||||
targetRef,
|
||||
],
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
logInfo(`Pushing backup refs ${backupBranch} and ${backupTag} to origin...`);
|
||||
run("git", ["push", "origin", `refs/heads/${backupBranch}`], {
|
||||
cwd: rootDir,
|
||||
});
|
||||
run("git", ["push", "origin", `refs/tags/${backupTag}`], { cwd: rootDir });
|
||||
|
||||
logSuccess("Backup complete. Recovery refs:");
|
||||
logSuccess(` branch: ${backupBranch}`);
|
||||
logSuccess(` tag: ${backupTag}`);
|
||||
logSuccess(` mirror: ${mirrorDir}`);
|
||||
logWarn("To restore original history later:");
|
||||
logWarn(
|
||||
` git push --force origin refs/heads/${backupBranch}:refs/heads/${target}`,
|
||||
);
|
||||
|
||||
logInfo(`Rewriting history of ${target}...`);
|
||||
run("git", ["checkout", "-B", target, targetRef], { cwd: rootDir });
|
||||
run("git", ["checkout", "--orphan", `temp_wipe_${target}`], { cwd: rootDir });
|
||||
run("git", ["add", "-A"], { cwd: rootDir });
|
||||
run("git", ["commit", "-m", "Initial commit"], { cwd: rootDir });
|
||||
run("git", ["branch", "-D", target], { cwd: rootDir });
|
||||
run("git", ["branch", "-m", target], { cwd: rootDir });
|
||||
logInfo(`Force-pushing rewritten history to origin/${target}...`);
|
||||
run("git", ["push", "--force", "origin", target], { cwd: rootDir });
|
||||
|
||||
logInfo("Deleting GitHub workflow runs associated with the old history...");
|
||||
deleteGitHubRuns();
|
||||
|
||||
logSuccess("Wipe commit history complete.");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
13295
package-lock.json
generated
13295
package-lock.json
generated
File diff suppressed because it is too large
Load diff
89
package.json
89
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "cache-apt-pkgs-action",
|
||||
"version": "0.0.0-semantically-released",
|
||||
"description": "TypeScript APT and APT-fast package management library",
|
||||
"description": "Install APT based packages and cache them for future runs",
|
||||
"author": "Andrew Walsh",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
|
|
@ -10,6 +10,10 @@
|
|||
"apt-get",
|
||||
"apt-fast"
|
||||
],
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/awalsh128"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awalsh128/cache-apt-pkgs-action.git"
|
||||
|
|
@ -40,45 +44,72 @@
|
|||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"bundle": "ncc build src/main.ts --out dist --license LICENSE",
|
||||
"runall": "node ./node_modules/npm-run-all/bin/npm-run-all/index.js",
|
||||
"build:lib": "tsc -p tsconfig.build.json",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --noEmit",
|
||||
"build": "npm run -s runall -- build:lib build:scripts",
|
||||
"clean": "rm -rf dist coverage",
|
||||
"check:latest-action-pin": "node ./dev_scripts/check_latest_action_pin.mjs",
|
||||
"check:latest-action-pins": "node ./dev_scripts/check_latest_action_pin.mjs --scan",
|
||||
"check:configs": "node ./dev_scripts/verify_configs.mjs",
|
||||
"check": "npm-run-all check:*",
|
||||
"format:write": "npx prettier --write .",
|
||||
"format:check": "npx prettier --check .",
|
||||
"setup:dev": "node ./dev_scripts/setup_devenv.mjs",
|
||||
"lint": "eslint src test --ext .ts",
|
||||
"package": "npx rimraf ./dist && npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
||||
"package:watch": "npm run package -- --watch",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test": "npm-run-all test:*"
|
||||
"check:latest-action-pin": "node --experimental-strip-types ./scripts/ops/check_latest_action_pin.mts",
|
||||
"check:latest-action-pins": "node --experimental-strip-types ./scripts/ops/check_latest_action_pin.mts --scan",
|
||||
"check:pr": "node --experimental-strip-types ./scripts/ops/pr_checks.mts",
|
||||
"check:configs": "node --experimental-strip-types ./scripts/ops/check_configs.mts",
|
||||
"check": "npm run -s runall -- check:latest-action-pins check:pr check:configs",
|
||||
"dev:tsapt:link": "npm link ../ts-apt --save; echo \"NOTE: npm run dev:tsapt:unlink when you are finished.\"",
|
||||
"dev:tsapt:unlink": "npm unlink ts-apt && npm install ts-apt",
|
||||
"gh:auth": "gh auth status >/dev/null 2>&1 || gh auth login --web && gh auth setup-git",
|
||||
"setup:dev": "node --experimental-strip-types ./scripts/dev/setup_devenv.mts",
|
||||
"repo:sync": "node --experimental-strip-types ./scripts/dev/gh_sync.mts",
|
||||
"repo:rulesets:download": "npm run repo::sync -- download --target rulesets",
|
||||
"repo:rulesets:upload": "npm run repo::sync -- upload --target rulesets",
|
||||
"repo:settings:download": "npm run repo::sync -- download --target settings",
|
||||
"repo:settings:upload": "npm run repo::sync -- upload --target settings",
|
||||
"repo:vars:download": "npm run repo::sync -- download --target vars",
|
||||
"repo:vars:upload": "npm run repo::sync -- upload --target vars",
|
||||
"repo:tags:download": "npm run repo::sync -- download --target tags",
|
||||
"repo:tags:upload": "npm run repo::sync -- upload --target tags",
|
||||
"repo:all:download": "npm run repo::sync -- download --target all",
|
||||
"repo:all:upload": "npm run repo::sync -- upload --target all",
|
||||
"docs:api": "typedoc --options typedoc.json",
|
||||
"release": "semantic-release",
|
||||
"release:dry-run": "semantic-release --dry-run",
|
||||
"lint": "eslint src test scripts --ext .ts,.mts",
|
||||
"typecheck:lib": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit",
|
||||
"typecheck": "npm run -s runall -- typecheck:lib typecheck:scripts",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/artifact": "^6.2.1",
|
||||
"@actions/cache": "^6.1.0",
|
||||
"@actions/core": "^3.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"rollup": "^4.62.2",
|
||||
"tar": "^7.4.3",
|
||||
"ts-apt": "file:../ts-apt",
|
||||
"winston": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.0",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.39.4",
|
||||
"jest": "^30.4.2",
|
||||
"@devcontainers/cli": "^0.87.0",
|
||||
"@robingenz/zli": "^0.2.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.6",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^14.1.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/parser": "^8.62.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.6.0",
|
||||
"internet-message": "^1.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"semantic-release": "^25.0.5",
|
||||
"type-coverage": "^2.29.7",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.1": true,
|
||||
"esbuild@0.27.7": true,
|
||||
"esbuild@0.27.0": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
"typedoc": "^0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.12.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
release.config.ts
Normal file
54
release.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @type {import('semantic-release').GlobalConfig}
|
||||
*/
|
||||
export default {
|
||||
// The 'branches' array defines the release workflow and versioning strategy.
|
||||
// Order matters: list branches from least stable to most stable.
|
||||
branches: [
|
||||
// Development branch is assumed to be from local development and may contain breaking changes.
|
||||
|
||||
// Pre-release candidate, feature complete, but may contain known bugs.
|
||||
//
|
||||
// 'staging' is for release candidates.
|
||||
// 'channel: "next"' publishes to the '@next' tag on npm.
|
||||
// 'prerelease: "rc"' adds the '-rc.x' suffix (e.g., v1.1.0-rc.1).
|
||||
{ name: "staging", channel: "next", prerelease: "rc" },
|
||||
|
||||
// Final and stable release.
|
||||
//
|
||||
// No prerelease tag and are published to the 'latest' dist-tag on npm.
|
||||
"main",
|
||||
],
|
||||
|
||||
// Actions to perform during a release.
|
||||
plugins: [
|
||||
// Analyzes commit messages (Conventional Commits) to determine if the next version should be a major,
|
||||
// minor, or patch bump.
|
||||
"@semantic-release/commit-analyzer",
|
||||
|
||||
// Generates the release notes (changelog content) based on the commit history.
|
||||
"@semantic-release/release-notes-generator",
|
||||
|
||||
// Updates the 'CHANGELOG.md' file with the new release notes.
|
||||
["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
|
||||
|
||||
// Updates 'package.json' with the new version and publishes the package to npm.
|
||||
// 'npmPublish: true' enables publishing; 'pkgRoot' sets the directory.
|
||||
["@semantic-release/npm", { npmPublish: true, pkgRoot: "." }],
|
||||
|
||||
// Commits the updated 'CHANGELOG.md', 'package.json', and 'package-lock.json' back to the repository with
|
||||
// a standardized commit message.
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
assets: ["CHANGELOG.md", "package.json", "package-lock.json"],
|
||||
message:
|
||||
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
|
||||
},
|
||||
],
|
||||
|
||||
// Creates a GitHub Release with the generated notes and uploads assets.
|
||||
// Default behavior posts automatic comments on resolved issues/PRs.
|
||||
"@semantic-release/github",
|
||||
],
|
||||
};
|
||||
380
scripts/dev/create_testcase_logs.mts
Executable file
380
scripts/dev/create_testcase_logs.mts
Executable file
|
|
@ -0,0 +1,380 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
/// <reference types="node" />
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { dirname, resolve } from "path";
|
||||
import process from "process";
|
||||
import { ROOT_DIR, fail, logInfo, logSuccess } from "../devopslib.mts";
|
||||
|
||||
type LogResult = {
|
||||
cmdLine: string;
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type TestLogEntry = {
|
||||
filepath: string;
|
||||
execCmdLine: string;
|
||||
preExecCmdLine?: string[];
|
||||
postExecCmdLine?: string[];
|
||||
};
|
||||
|
||||
function toLog(result: LogResult): string {
|
||||
return `*** CMD_LINE ${result.cmdLine}\n*** EXIT_CODE ${result.exitCode}\n*** STDOUT\n${result.stdout}*** STDERR\n${result.stderr}`;
|
||||
}
|
||||
|
||||
/** Determine exit code: use code if available, else map signal to 137 (SIGKILL) or 143 (SIGTERM) */
|
||||
function getFinalExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
} else if (signal === "SIGTERM") {
|
||||
return 143; // Standard for SIGTERM (128 + 14)
|
||||
} else if (signal === "SIGKILL") {
|
||||
return 137; // Standard for SIGKILL (128 + 9)
|
||||
}
|
||||
return 1; // Fallback for unknown signal
|
||||
}
|
||||
|
||||
async function execute(cmdLine: string): Promise<LogResult> {
|
||||
return await new Promise<LogResult>((resolve, reject) => {
|
||||
const parts = cmdLine.trim().split(/\s+/);
|
||||
const [command, ...args] = parts;
|
||||
if (!command) {
|
||||
reject(new Error("Command line cannot be empty."));
|
||||
return;
|
||||
}
|
||||
const env = { ...process.env };
|
||||
if (!env.LC_ALL) {
|
||||
env.LC_ALL = "C";
|
||||
}
|
||||
|
||||
const commonEnv = {
|
||||
...env,
|
||||
PATH: `${process.env.PATH}:/usr/bin`,
|
||||
DEBIAN_FRONTEND: "noninteractive",
|
||||
DEBCONF_NONINTERACTIVE_SEEN: "true",
|
||||
};
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env: commonEnv,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
/**
|
||||
* Ensures promise resolution or rejection happens once.
|
||||
*
|
||||
* @param fn Finalizer callback to execute once settled.
|
||||
*/
|
||||
const finalize = (fn: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
fn();
|
||||
};
|
||||
|
||||
// 1. Attach 'error' FIRST (Critical for crash safety)
|
||||
child.on("error", (error: Error) => {
|
||||
finalize(() => reject(error));
|
||||
});
|
||||
|
||||
// 2. Attach 'stdout' and 'stderr' stream listeners (Capture output)
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
const str = chunk.toString("utf8");
|
||||
stdout += str;
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const str = chunk.toString("utf8");
|
||||
stderr += str;
|
||||
});
|
||||
|
||||
// 3. Attach 'close' last (Handles exit logic)
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (stderr.includes("Permission denied")) {
|
||||
finalize(() =>
|
||||
reject(
|
||||
new Error(`Permission denied error:
|
||||
- command: ${command}
|
||||
- args: ${args.join(" ")}
|
||||
- stdout: ${stdout}
|
||||
- stderr: ${stderr}
|
||||
- code: ${code}
|
||||
- signal: ${signal}
|
||||
`),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
finalize(() => {
|
||||
const finalExitCode = getFinalExitCode(code, signal);
|
||||
|
||||
resolve({
|
||||
cmdLine,
|
||||
exitCode: finalExitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeLogFile(
|
||||
baseDir: string,
|
||||
relativePath: string,
|
||||
result: LogResult,
|
||||
): Promise<string> {
|
||||
const outputPath = resolve(baseDir, `${relativePath}.log`);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, toLog(result), "utf8");
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED_EXIT_CODE = 100;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testDataDir = resolve(ROOT_DIR, "test/data");
|
||||
let written = 0;
|
||||
|
||||
for (const entry of testLogFiles) {
|
||||
if (entry.preExecCmdLine) {
|
||||
for (const cmd of entry.preExecCmdLine) {
|
||||
await execute(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await execute(entry.execCmdLine);
|
||||
const outputPath = await writeLogFile(testDataDir, entry.filepath, result);
|
||||
if (entry.postExecCmdLine) {
|
||||
for (const cmd of entry.postExecCmdLine) {
|
||||
await execute(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
written += 1;
|
||||
logInfo(`Wrote ${outputPath}`);
|
||||
}
|
||||
|
||||
await execute("apt-get upgrade -y");
|
||||
await execute("apt-get autoremove -y");
|
||||
|
||||
logSuccess(`Wrote ${written} command execution log(s).`);
|
||||
}
|
||||
|
||||
const testLogFiles: TestLogEntry[] = [
|
||||
{
|
||||
filepath: "autoclean",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoclean",
|
||||
},
|
||||
{
|
||||
filepath: "autoremove_found",
|
||||
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "autoremove_notfound",
|
||||
preExecCmdLine: ["apt-get autoremove -y"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_mixedfoundnotfound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3 nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_multiplefound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_singlefound",
|
||||
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show python3",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_singlenotfound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-cache --quiet=0 --no-all-versions show nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_virtualpackage",
|
||||
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show libvips",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshow_virtualandnotpackages",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-cache --quiet=0 --no-all-versions show libvips xdot",
|
||||
},
|
||||
{
|
||||
filepath: "cacheshowpkg_virtualpackage",
|
||||
execCmdLine: "apt-cache showpkg --quiet=0 libvips",
|
||||
},
|
||||
{
|
||||
filepath: "install_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "install_mixedinstallstatus",
|
||||
preExecCmdLine: [
|
||||
"apt-get install -y xdot",
|
||||
"apt-get remove -y rolldice",
|
||||
"apt-get autoremove -y",
|
||||
],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f rolldice xdot",
|
||||
},
|
||||
{
|
||||
filepath: "install_singleinstalled",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
|
||||
},
|
||||
{
|
||||
filepath: "install_singlenotfound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "install_singlenotinstalled",
|
||||
preExecCmdLine: ["apt-get remove -y xdot", "apt-get autoremove -y"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalled",
|
||||
execCmdLine: "/usr/bin/dpkg-query -W -f ${binary:Package}=${Version}\\n",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalledfiles_found",
|
||||
execCmdLine: "/usr/bin/dpkg-query -L xdot",
|
||||
},
|
||||
{
|
||||
filepath: "listinstalledfiles_notfound",
|
||||
execCmdLine: "/usr/bin/dpkg-query -L nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "listupgradable_found",
|
||||
preExecCmdLine: [
|
||||
"apt-get remove -y firefox-locale-en",
|
||||
"apt-get install -y firefox-locale-en=75.0+build3-0ubuntu1",
|
||||
"apt-get remove -y uuid-dev",
|
||||
"apt-get install -y uuid-dev=2.34-0.1ubuntu9",
|
||||
],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o APT::Get::Show-User-Simulation-Note=false -V --simulate dist-upgrade",
|
||||
},
|
||||
{
|
||||
filepath: "listupgradable_notfound",
|
||||
preExecCmdLine: ["apt-get upgrade -y"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o APT::Get::Show-User-Simulation-Note=false -V --simulate dist-upgrade",
|
||||
},
|
||||
{
|
||||
filepath: "remove_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot nonexistentpackage --autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "remove_mixedinstallstatus",
|
||||
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y rolldice"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f rolldice xdot --autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singlenotinstalled",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singlenotfound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f nonexistentpackage --autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "remove_singleinstalled",
|
||||
preExecCmdLine: ["apt-get install -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
|
||||
},
|
||||
{
|
||||
filepath: "search_multiplefound",
|
||||
execCmdLine: "/usr/bin/apt-cache --quiet=0 search vim",
|
||||
},
|
||||
{
|
||||
filepath: "search_nonefound",
|
||||
execCmdLine: "/usr/bin/apt-cache --quiet=0 search nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "search_singlefound",
|
||||
execCmdLine: "/usr/bin/apt-cache --quiet=0 search vim-vimerl-syntax",
|
||||
},
|
||||
{
|
||||
filepath: "update",
|
||||
execCmdLine: "flock /var/lib/apt/lists/lock /usr/bin/apt-get update",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_mixedfoundnotfound",
|
||||
preExecCmdLine: ["apt-get remove -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_mixedupgradestatus",
|
||||
preExecCmdLine: [
|
||||
"apt-get install -y xdot",
|
||||
"apt-get remove -y xxd",
|
||||
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
|
||||
],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd xdot",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singleupgraded",
|
||||
preExecCmdLine: ["apt-get upgrade -y xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 upgrade",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singlenotfound",
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install nonexistentpackage",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singlenotupgraded",
|
||||
preExecCmdLine: [
|
||||
"apt-get remove -y xxd",
|
||||
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
|
||||
],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd",
|
||||
},
|
||||
{
|
||||
filepath: "upgrade_singleinstalled",
|
||||
preExecCmdLine: ["apt-get install xdot"],
|
||||
execCmdLine:
|
||||
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot",
|
||||
},
|
||||
];
|
||||
|
||||
await main().catch((error) => {
|
||||
fail(
|
||||
"Error occurred during log creation:\n" +
|
||||
String(error) +
|
||||
"\n" +
|
||||
(error.stack || ""),
|
||||
);
|
||||
});
|
||||
31
scripts/dev/gh_auth.sh
Executable file
31
scripts/dev/gh_auth.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Checking GitHub authentication status..."
|
||||
|
||||
# 1. Check if logged in; if not, log in via browser UI
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
echo "⚠️ Not authenticated. Opening browser for login..."
|
||||
gh auth login --web
|
||||
else
|
||||
echo "✅ Already authenticated."
|
||||
fi
|
||||
|
||||
# 2. Configure Git to use gh credentials
|
||||
echo "⚙️ Configuring Git credential helper..."
|
||||
# Clear existing helpers to avoid "multiple values" error
|
||||
git config --global --unset-all credential.helper 2>/dev/null || true
|
||||
git config --global --add credential.helper '!gh auth git-credential'
|
||||
|
||||
# 3. Export GITHUB_TOKEN for Node.js scripts (current session)
|
||||
echo "🔑 Exporting GITHUB_TOKEN for API access..."
|
||||
GITHUB_TOKEN=$(gh auth token)
|
||||
export GITHUB_TOKEN
|
||||
|
||||
echo "✅ Setup complete!"
|
||||
echo " - Git is configured to use gh."
|
||||
echo " - GITHUB_TOKEN is exported for this session."
|
||||
echo ""
|
||||
echo "🚀 You can now run your script:"
|
||||
echo " npm run check:latest-action-pins"
|
||||
833
scripts/dev/gh_sync.mts
Normal file
833
scripts/dev/gh_sync.mts
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
/**
|
||||
* Sync GitHub repository metadata using GitHub CLI.
|
||||
*
|
||||
* Supported targets:
|
||||
* - `settings`: repository settings
|
||||
* - `rulesets`: repository rulesets and desired bypass actors
|
||||
* - `vars`: repository Actions variables
|
||||
* - `tags`: repository Git tags
|
||||
* - `all`: every supported target
|
||||
*
|
||||
* Examples:
|
||||
* npm run repo:settings:download
|
||||
* npm run repo:rulesets:upload -- --dry-run
|
||||
* node --experimental-strip-types ./scripts/dev/gh_sync.mts download --target vars
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { defineCommand, defineOptions } from "@robingenz/zli";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
commandExists,
|
||||
createCliConfig,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
runCli,
|
||||
runMain,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type SyncTarget = "settings" | "rulesets" | "vars" | "tags" | "all";
|
||||
|
||||
type RepoSettings = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
homepage?: string | null;
|
||||
default_branch?: string;
|
||||
has_issues?: boolean;
|
||||
has_projects?: boolean;
|
||||
has_wiki?: boolean;
|
||||
is_template?: boolean;
|
||||
allow_squash_merge?: boolean;
|
||||
allow_merge_commit?: boolean;
|
||||
allow_rebase_merge?: boolean;
|
||||
allow_auto_merge?: boolean;
|
||||
delete_branch_on_merge?: boolean;
|
||||
allow_update_branch?: boolean;
|
||||
web_commit_signoff_required?: boolean;
|
||||
squash_merge_commit_title?: string;
|
||||
squash_merge_commit_message?: string;
|
||||
merge_commit_title?: string;
|
||||
merge_commit_message?: string;
|
||||
security_and_analysis?: {
|
||||
advanced_security?: { status?: string };
|
||||
secret_scanning?: { status?: string };
|
||||
secret_scanning_push_protection?: { status?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type SettingsFile = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
exportedAt: string;
|
||||
settings: RepoSettings;
|
||||
};
|
||||
|
||||
type BypassActor = {
|
||||
actor_id: number;
|
||||
actor_type:
|
||||
| "RepositoryRole"
|
||||
| "Team"
|
||||
| "Integration"
|
||||
| "OrganizationAdmin"
|
||||
| "DeployKey";
|
||||
bypass_mode: "always" | "pull_request";
|
||||
};
|
||||
|
||||
type Ruleset = {
|
||||
id: number;
|
||||
name: string;
|
||||
target: string;
|
||||
enforcement: string;
|
||||
conditions?: {
|
||||
ref_name?: {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
};
|
||||
};
|
||||
rules?: Array<Record<string, unknown>>;
|
||||
bypass_actors: BypassActor[];
|
||||
};
|
||||
|
||||
type AppBypassConfig = {
|
||||
appId: number;
|
||||
appLabel?: string;
|
||||
bypassMode: "always" | "pull_request";
|
||||
};
|
||||
|
||||
type RulesetsFile = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
exportedAt: string;
|
||||
appsBypassActors: AppBypassConfig[];
|
||||
rulesets: Ruleset[];
|
||||
};
|
||||
|
||||
type RepoVariable = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type VariablesFile = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
exportedAt: string;
|
||||
variables: RepoVariable[];
|
||||
};
|
||||
|
||||
type RepoTag = {
|
||||
name: string;
|
||||
sha: string;
|
||||
};
|
||||
|
||||
type TagsFile = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
exportedAt: string;
|
||||
tags: RepoTag[];
|
||||
};
|
||||
|
||||
const DEFAULT_TARGET_PATHS = {
|
||||
settings: ".github/repo-settings.json",
|
||||
rulesets: ".github/repo-rulesets.json",
|
||||
vars: ".github/repo-vars.json",
|
||||
tags: ".github/repo-tags.json",
|
||||
} as const;
|
||||
|
||||
const SETTINGS_KEYS: Array<keyof RepoSettings> = [
|
||||
"name",
|
||||
"description",
|
||||
"homepage",
|
||||
"default_branch",
|
||||
"has_issues",
|
||||
"has_projects",
|
||||
"has_wiki",
|
||||
"is_template",
|
||||
"allow_squash_merge",
|
||||
"allow_merge_commit",
|
||||
"allow_rebase_merge",
|
||||
"allow_auto_merge",
|
||||
"delete_branch_on_merge",
|
||||
"allow_update_branch",
|
||||
"web_commit_signoff_required",
|
||||
"squash_merge_commit_title",
|
||||
"squash_merge_commit_message",
|
||||
"merge_commit_title",
|
||||
"merge_commit_message",
|
||||
"security_and_analysis",
|
||||
];
|
||||
|
||||
function runGh(args: string[], input?: string): string {
|
||||
const result = spawnSync("gh", args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
input,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim() || "unknown error";
|
||||
fail(`gh ${args.join(" ")} failed: ${stderr}`);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function ensureDirForFile(filePath: string): string {
|
||||
const absPath = path.resolve(filePath);
|
||||
mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
return absPath;
|
||||
}
|
||||
|
||||
function writeJsonFile(
|
||||
filePath: string,
|
||||
payload: unknown,
|
||||
successLabel: string,
|
||||
): void {
|
||||
const absPath = ensureDirForFile(filePath);
|
||||
writeFileSync(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
logSuccess(`Wrote ${successLabel} to ${absPath}`);
|
||||
}
|
||||
|
||||
function readJsonFile<T>(filePath: string, defaultValue: T): T {
|
||||
const absPath = path.resolve(filePath);
|
||||
const raw = readFileSync(absPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as T | null;
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
fail(`Invalid JSON in ${absPath}`);
|
||||
}
|
||||
|
||||
return { ...defaultValue, ...parsed };
|
||||
}
|
||||
|
||||
function writeTempPayload(payload: unknown, prefix: string): string {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const payloadFile = path.join(tempDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify(payload), "utf8");
|
||||
return payloadFile;
|
||||
}
|
||||
|
||||
function withTempPayload<T>(
|
||||
payload: unknown,
|
||||
prefix: string,
|
||||
fn: (payloadFile: string) => T,
|
||||
): T {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const payloadFile = path.join(tempDir, "payload.json");
|
||||
|
||||
try {
|
||||
writeFileSync(payloadFile, JSON.stringify(payload), "utf8");
|
||||
return fn(payloadFile);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function pickSettings(repoJson: Record<string, unknown>): RepoSettings {
|
||||
const settings: RepoSettings = {};
|
||||
|
||||
for (const key of SETTINGS_KEYS) {
|
||||
if (!(key in repoJson)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(settings as Record<string, unknown>)[key] = repoJson[key] as unknown;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
function buildPatchPayload(settings: RepoSettings): RepoSettings {
|
||||
const payload: RepoSettings = {};
|
||||
|
||||
for (const key of SETTINGS_KEYS) {
|
||||
const value = settings[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(payload as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readSettingsFile(filePath: string): SettingsFile {
|
||||
return readJsonFile<SettingsFile>(filePath, {
|
||||
owner: REPO_OWNER,
|
||||
repo: REPO_NAME,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
settings: {},
|
||||
});
|
||||
}
|
||||
|
||||
function downloadSettings(owner: string, repo: string, filePath: string): void {
|
||||
logInfo(`Downloading repository settings for ${owner}/${repo}...`);
|
||||
const output = runGh(["api", `repos/${owner}/${repo}`]);
|
||||
const repoJson = JSON.parse(output) as Record<string, unknown>;
|
||||
const payload: SettingsFile = {
|
||||
owner,
|
||||
repo,
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: pickSettings(repoJson),
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, payload, "repository settings");
|
||||
}
|
||||
|
||||
function uploadSettings(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
const settingsFile = readSettingsFile(filePath);
|
||||
const payload = buildPatchPayload(settingsFile.settings);
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
fail("Settings payload is empty. Nothing to upload.");
|
||||
}
|
||||
|
||||
logInfo(`Uploading repository settings to ${owner}/${repo}...`);
|
||||
|
||||
if (dryRun) {
|
||||
logInfo("Dry-run mode enabled. Payload that would be uploaded:");
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
logSuccess("Dry-run complete. No remote changes were made.");
|
||||
return;
|
||||
}
|
||||
|
||||
withTempPayload(payload, "ts-apt-repo-settings-", (payloadFile) => {
|
||||
runGh([
|
||||
"api",
|
||||
"-X",
|
||||
"PATCH",
|
||||
`repos/${owner}/${repo}`,
|
||||
"--input",
|
||||
payloadFile,
|
||||
]);
|
||||
});
|
||||
|
||||
logSuccess(`Uploaded repository settings to ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
function readRulesetsFile(filePath: string): RulesetsFile {
|
||||
return readJsonFile<RulesetsFile>(filePath, {
|
||||
owner: REPO_OWNER,
|
||||
repo: REPO_NAME,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
appsBypassActors: [],
|
||||
rulesets: [],
|
||||
});
|
||||
}
|
||||
|
||||
function fetchRulesets(owner: string, repo: string): Ruleset[] {
|
||||
const listRaw = runGh(["api", `/repos/${owner}/${repo}/rulesets`]);
|
||||
const list = JSON.parse(listRaw) as Array<{ id: number }>;
|
||||
|
||||
return list.map((entry) => {
|
||||
const detailRaw = runGh([
|
||||
"api",
|
||||
`/repos/${owner}/${repo}/rulesets/${entry.id}`,
|
||||
]);
|
||||
return JSON.parse(detailRaw) as Ruleset;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeBypassActors(
|
||||
ruleset: Ruleset,
|
||||
appsBypassActors: AppBypassConfig[],
|
||||
): BypassActor[] {
|
||||
const desiredActors = [...ruleset.bypass_actors];
|
||||
|
||||
for (const cfg of appsBypassActors) {
|
||||
const exists = desiredActors.some(
|
||||
(actor) =>
|
||||
actor.actor_id === cfg.appId && actor.actor_type === "Integration",
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
desiredActors.push({
|
||||
actor_id: cfg.appId,
|
||||
actor_type: "Integration",
|
||||
bypass_mode: cfg.bypassMode,
|
||||
});
|
||||
}
|
||||
|
||||
return desiredActors;
|
||||
}
|
||||
|
||||
function buildRulesetPutPayload(
|
||||
ruleset: Ruleset,
|
||||
appsBypassActors: AppBypassConfig[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
name: ruleset.name,
|
||||
target: ruleset.target,
|
||||
enforcement: ruleset.enforcement,
|
||||
conditions: ruleset.conditions ?? {
|
||||
ref_name: { include: ["~ALL"], exclude: [] },
|
||||
},
|
||||
rules: ruleset.rules ?? [],
|
||||
bypass_actors: mergeBypassActors(ruleset, appsBypassActors),
|
||||
};
|
||||
}
|
||||
|
||||
function downloadRulesets(owner: string, repo: string, filePath: string): void {
|
||||
logInfo(`Downloading rulesets for ${owner}/${repo}...`);
|
||||
const rulesets = fetchRulesets(owner, repo);
|
||||
|
||||
const existing = (() => {
|
||||
try {
|
||||
return readRulesetsFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const payload: RulesetsFile = {
|
||||
owner,
|
||||
repo,
|
||||
exportedAt: new Date().toISOString(),
|
||||
appsBypassActors: existing?.appsBypassActors ?? [],
|
||||
rulesets,
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, payload, "rulesets");
|
||||
logInfo(`Found ${rulesets.length} ruleset(s).`);
|
||||
}
|
||||
|
||||
function uploadRulesets(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
const config = readRulesetsFile(filePath);
|
||||
|
||||
if (config.rulesets.length === 0) {
|
||||
logWarn("No rulesets configured; nothing to apply.");
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`Fetching current rulesets for ${owner}/${repo}...`);
|
||||
const remoteRulesets = fetchRulesets(owner, repo);
|
||||
const remoteById = new Map(
|
||||
remoteRulesets.map((ruleset) => [ruleset.id, ruleset]),
|
||||
);
|
||||
|
||||
if (remoteRulesets.length === 0) {
|
||||
logWarn("No rulesets found in the repository.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ruleset of config.rulesets) {
|
||||
const remoteRuleset = remoteById.get(ruleset.id);
|
||||
|
||||
if (!remoteRuleset) {
|
||||
fail(`Ruleset "${ruleset.name}" (${ruleset.id}) was not found remotely.`);
|
||||
}
|
||||
|
||||
const payload = buildRulesetPutPayload(ruleset, config.appsBypassActors);
|
||||
logInfo(
|
||||
`Ruleset "${ruleset.name}" (${ruleset.id}): syncing full definition...`,
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
logInfo(
|
||||
`Dry-run mode: would PUT with ${JSON.stringify(payload, null, 2)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
runGh(
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"PUT",
|
||||
`/repos/${owner}/${repo}/rulesets/${ruleset.id}`,
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
|
||||
logSuccess(`Ruleset "${ruleset.name}" (${ruleset.id}) synchronized.`);
|
||||
}
|
||||
|
||||
logSuccess(
|
||||
dryRun
|
||||
? "Dry-run complete. No remote changes were made."
|
||||
: "Rulesets applied successfully.",
|
||||
);
|
||||
}
|
||||
|
||||
function readVariablesFile(filePath: string): VariablesFile {
|
||||
return readJsonFile<VariablesFile>(filePath, {
|
||||
owner: REPO_OWNER,
|
||||
repo: REPO_NAME,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
variables: [],
|
||||
});
|
||||
}
|
||||
|
||||
function fetchVariables(owner: string, repo: string): RepoVariable[] {
|
||||
const output = runGh(["api", `/repos/${owner}/${repo}/actions/variables`]);
|
||||
const parsed = JSON.parse(output) as {
|
||||
variables?: Array<{ name: string; value: string }>;
|
||||
};
|
||||
return (parsed.variables ?? [])
|
||||
.map((variable) => ({ name: variable.name, value: variable.value }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function downloadVariables(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): void {
|
||||
logInfo(`Downloading repository variables for ${owner}/${repo}...`);
|
||||
const payload: VariablesFile = {
|
||||
owner,
|
||||
repo,
|
||||
exportedAt: new Date().toISOString(),
|
||||
variables: fetchVariables(owner, repo),
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, payload, "repository variables");
|
||||
}
|
||||
|
||||
function uploadVariables(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
const config = readVariablesFile(filePath);
|
||||
const remoteVariables = fetchVariables(owner, repo);
|
||||
const remoteByName = new Map(
|
||||
remoteVariables.map((variable) => [variable.name, variable]),
|
||||
);
|
||||
|
||||
if (config.variables.length === 0) {
|
||||
logWarn("No repository variables configured; nothing to apply.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const variable of config.variables) {
|
||||
const existing = remoteByName.get(variable.name);
|
||||
|
||||
if (!existing) {
|
||||
logInfo(`Repository variable "${variable.name}": creating...`);
|
||||
if (!dryRun) {
|
||||
runGh(
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/actions/variables`,
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
JSON.stringify(variable),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.value === variable.value) {
|
||||
logInfo(`Repository variable "${variable.name}": already up to date.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logInfo(`Repository variable "${variable.name}": updating...`);
|
||||
if (!dryRun) {
|
||||
runGh(
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"PATCH",
|
||||
`/repos/${owner}/${repo}/actions/variables/${variable.name}`,
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
JSON.stringify({ value: variable.value }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logSuccess(
|
||||
dryRun
|
||||
? "Dry-run complete. No remote changes were made."
|
||||
: "Repository variables applied successfully.",
|
||||
);
|
||||
}
|
||||
|
||||
function readTagsFile(filePath: string): TagsFile {
|
||||
return readJsonFile<TagsFile>(filePath, {
|
||||
owner: REPO_OWNER,
|
||||
repo: REPO_NAME,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
|
||||
function fetchTags(owner: string, repo: string): RepoTag[] {
|
||||
const output = runGh(["api", `/repos/${owner}/${repo}/tags?per_page=100`]);
|
||||
const parsed = JSON.parse(output) as Array<{
|
||||
name: string;
|
||||
commit?: { sha?: string };
|
||||
}>;
|
||||
return parsed
|
||||
.map((tag) => ({ name: tag.name, sha: tag.commit?.sha ?? "" }))
|
||||
.filter((tag) => tag.sha.length > 0)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function downloadTags(owner: string, repo: string, filePath: string): void {
|
||||
logInfo(`Downloading repository tags for ${owner}/${repo}...`);
|
||||
const payload: TagsFile = {
|
||||
owner,
|
||||
repo,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tags: fetchTags(owner, repo),
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, payload, "repository tags");
|
||||
}
|
||||
|
||||
function uploadTags(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
const config = readTagsFile(filePath);
|
||||
const remoteTags = fetchTags(owner, repo);
|
||||
const remoteByName = new Map(remoteTags.map((tag) => [tag.name, tag]));
|
||||
|
||||
if (config.tags.length === 0) {
|
||||
logWarn("No repository tags configured; nothing to apply.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tag of config.tags) {
|
||||
const existing = remoteByName.get(tag.name);
|
||||
|
||||
if (!existing) {
|
||||
logInfo(`Repository tag "${tag.name}": creating at ${tag.sha}...`);
|
||||
if (!dryRun) {
|
||||
runGh(
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/git/refs`,
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
JSON.stringify({ ref: `refs/tags/${tag.name}`, sha: tag.sha }),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.sha === tag.sha) {
|
||||
logInfo(`Repository tag "${tag.name}": already up to date.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(
|
||||
`Repository tag "${tag.name}" points to ${existing.sha}, expected ${tag.sha}. ` +
|
||||
"Tag sync will not retarget existing tags automatically.",
|
||||
);
|
||||
}
|
||||
|
||||
logSuccess(
|
||||
dryRun
|
||||
? "Dry-run complete. No remote changes were made."
|
||||
: "Repository tags applied successfully.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFilePath(
|
||||
target: Exclude<SyncTarget, "all">,
|
||||
fileOverride?: string,
|
||||
): string {
|
||||
return fileOverride?.trim() || DEFAULT_TARGET_PATHS[target];
|
||||
}
|
||||
|
||||
function applyTarget(
|
||||
action: "download" | "upload",
|
||||
target: Exclude<SyncTarget, "all">,
|
||||
owner: string,
|
||||
repo: string,
|
||||
fileOverride: string | undefined,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
const filePath = resolveFilePath(target, fileOverride);
|
||||
|
||||
if (target === "settings") {
|
||||
if (action === "download") {
|
||||
downloadSettings(owner, repo, filePath);
|
||||
} else {
|
||||
uploadSettings(owner, repo, filePath, dryRun);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === "rulesets") {
|
||||
if (action === "download") {
|
||||
downloadRulesets(owner, repo, filePath);
|
||||
} else {
|
||||
uploadRulesets(owner, repo, filePath, dryRun);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === "vars") {
|
||||
if (action === "download") {
|
||||
downloadVariables(owner, repo, filePath);
|
||||
} else {
|
||||
uploadVariables(owner, repo, filePath, dryRun);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "download") {
|
||||
downloadTags(owner, repo, filePath);
|
||||
} else {
|
||||
uploadTags(owner, repo, filePath, dryRun);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTargets(
|
||||
action: "download" | "upload",
|
||||
target: SyncTarget,
|
||||
owner: string,
|
||||
repo: string,
|
||||
fileOverride: string | undefined,
|
||||
dryRun: boolean,
|
||||
): void {
|
||||
if (target === "all") {
|
||||
if (fileOverride?.trim()) {
|
||||
fail(
|
||||
"--file cannot be used with --target all. Use the default target files instead.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const currentTarget of [
|
||||
"settings",
|
||||
"rulesets",
|
||||
"vars",
|
||||
"tags",
|
||||
] as const) {
|
||||
applyTarget(action, currentTarget, owner, repo, undefined, dryRun);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyTarget(action, target, owner, repo, fileOverride, dryRun);
|
||||
}
|
||||
|
||||
function ensurePrerequisites(): void {
|
||||
if (!commandExists("gh")) {
|
||||
fail("GitHub CLI ('gh') is required.");
|
||||
}
|
||||
|
||||
const authCheck = spawnSync("gh", ["auth", "status"], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
if (authCheck.status !== 0) {
|
||||
fail("GitHub CLI is not authenticated. Run 'gh auth login' first.");
|
||||
}
|
||||
}
|
||||
|
||||
const sharedOptions = defineOptions(
|
||||
z.object({
|
||||
owner: z.string().default(REPO_OWNER).describe("GitHub repository owner"),
|
||||
repo: z.string().default(REPO_NAME).describe("GitHub repository name"),
|
||||
target: z
|
||||
.enum(["settings", "rulesets", "vars", "tags", "all"])
|
||||
.default("all")
|
||||
.describe("Metadata target to sync"),
|
||||
file: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Override the default JSON file path for a single target"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Print changes without applying them"),
|
||||
}),
|
||||
{ o: "owner", r: "repo", t: "target", f: "file", d: "dryRun" },
|
||||
);
|
||||
|
||||
const downloadCommand = defineCommand({
|
||||
description: "Download GitHub repository metadata to tracked JSON files",
|
||||
options: sharedOptions,
|
||||
action: async (options) => {
|
||||
applyTargets(
|
||||
"download",
|
||||
options.target,
|
||||
options.owner,
|
||||
options.repo,
|
||||
options.file,
|
||||
options.dryRun,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadCommand = defineCommand({
|
||||
description: "Upload GitHub repository metadata from tracked JSON files",
|
||||
options: sharedOptions,
|
||||
action: async (options) => {
|
||||
applyTargets(
|
||||
"upload",
|
||||
options.target,
|
||||
options.owner,
|
||||
options.repo,
|
||||
options.file,
|
||||
options.dryRun,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Sync GitHub repository settings, rulesets, variables, and tags",
|
||||
commands: {
|
||||
download: downloadCommand,
|
||||
upload: uploadCommand,
|
||||
},
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
ensurePrerequisites();
|
||||
await runCli(cliConfig);
|
||||
}
|
||||
|
||||
await runMain(main);
|
||||
162
scripts/dev/hotfix_pr.mts
Executable file
162
scripts/dev/hotfix_pr.mts
Executable file
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import process from "node:process";
|
||||
import { defineCommand } from "@robingenz/zli";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createCliConfig,
|
||||
ROOT_DIR,
|
||||
confirmPrompt,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
runCli,
|
||||
runMain,
|
||||
run,
|
||||
runCaptureOutput,
|
||||
tryRun,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type BranchName = string;
|
||||
|
||||
function branchExistsOnOrigin(branch: BranchName): boolean {
|
||||
return tryRun(
|
||||
"git",
|
||||
["ls-remote", "--exit-code", "--heads", "origin", branch],
|
||||
{ cwd: ROOT_DIR },
|
||||
).ok;
|
||||
}
|
||||
|
||||
function createOrCheckoutBranch(
|
||||
issueId: string,
|
||||
checkoutBranch: BranchName,
|
||||
baseBranch: BranchName,
|
||||
): void {
|
||||
if (branchExistsOnOrigin(checkoutBranch)) {
|
||||
logInfo(
|
||||
`Branch ${checkoutBranch} exists. Checking out and merging ${baseBranch}...`,
|
||||
);
|
||||
run("git", ["checkout", checkoutBranch], { cwd: ROOT_DIR });
|
||||
run("git", ["pull", "origin", checkoutBranch], { cwd: ROOT_DIR });
|
||||
run("git", ["merge", baseBranch], { cwd: ROOT_DIR });
|
||||
} else {
|
||||
logInfo(`Creating hotfix branch for issue ${issueId}...`);
|
||||
run("git", ["checkout", baseBranch], { cwd: ROOT_DIR });
|
||||
run("git", ["checkout", "-b", checkoutBranch], { cwd: ROOT_DIR });
|
||||
run("git", ["merge", baseBranch], { cwd: ROOT_DIR });
|
||||
}
|
||||
}
|
||||
|
||||
function commitIfNeeded(message: string): void {
|
||||
run("git", ["add", "."], { cwd: ROOT_DIR });
|
||||
const commit = tryRun("git", ["commit", "-m", message], { cwd: ROOT_DIR });
|
||||
if (commit.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const combined = `${commit.stdout}\n${commit.stderr}`;
|
||||
if (/nothing to commit|no changes added to commit/i.test(combined)) {
|
||||
logSuccess("No local changes to commit.");
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(commit.stderr);
|
||||
process.exit(commit.status ?? 1);
|
||||
}
|
||||
|
||||
function pushChanges(
|
||||
issueId: string,
|
||||
fixType: string,
|
||||
syncBranch: BranchName,
|
||||
syncBase: BranchName,
|
||||
): void {
|
||||
const message = `${fixType}: resolve critical production issue in #${issueId}`;
|
||||
commitIfNeeded(message);
|
||||
|
||||
const prUrl = runCaptureOutput(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--head",
|
||||
syncBranch,
|
||||
"--base",
|
||||
syncBase,
|
||||
"--state",
|
||||
"open",
|
||||
"--json",
|
||||
"url",
|
||||
"--jq",
|
||||
".[0].url",
|
||||
],
|
||||
{ cwd: ROOT_DIR },
|
||||
);
|
||||
|
||||
if (prUrl) {
|
||||
logSuccess(`PR already exists: ${prUrl}`);
|
||||
} else {
|
||||
logInfo("No PR found. Creating new PR...");
|
||||
run(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"create",
|
||||
"--head",
|
||||
syncBranch,
|
||||
"--base",
|
||||
syncBase,
|
||||
"--title",
|
||||
message,
|
||||
],
|
||||
{ cwd: ROOT_DIR },
|
||||
);
|
||||
}
|
||||
|
||||
logInfo(`Pushing changes from ${syncBase} to ${syncBranch}...`);
|
||||
run("git", ["push", "origin", syncBranch], { cwd: ROOT_DIR });
|
||||
}
|
||||
|
||||
const hotfixCommand = defineCommand({
|
||||
description: "Create or update hotfix and sync branches for an issue",
|
||||
args: z.tuple([
|
||||
z.string().describe("Issue ID"),
|
||||
z.string().describe("Target branch"),
|
||||
]),
|
||||
action: async (_options, args) => {
|
||||
const [issueId, base] = args;
|
||||
|
||||
if (!/^\d+$/.test(issueId)) {
|
||||
fail("Issue ID must be an integer.");
|
||||
}
|
||||
|
||||
const branchSuffix = `issue-${issueId}`;
|
||||
const hotfixBranch = `hotfix/${branchSuffix}`;
|
||||
|
||||
createOrCheckoutBranch(issueId, hotfixBranch, base);
|
||||
|
||||
await confirmPrompt(
|
||||
"Edit files and confirm to continue. This can always be rerun to pickup where you left off. Continue?",
|
||||
);
|
||||
|
||||
pushChanges(issueId, "fix", hotfixBranch, base);
|
||||
|
||||
const syncBranch = `sync/staging-${branchSuffix}`;
|
||||
createOrCheckoutBranch(issueId, syncBranch, hotfixBranch);
|
||||
pushChanges(issueId, "sync", syncBranch, "staging");
|
||||
},
|
||||
});
|
||||
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Create or update hotfix and sync branches for an issue",
|
||||
commands: {
|
||||
run: hotfixCommand,
|
||||
},
|
||||
defaultCommand: hotfixCommand,
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runCli(cliConfig);
|
||||
}
|
||||
|
||||
await runMain(main);
|
||||
234
dev_scripts/node_ver.mjs → scripts/dev/node_ver.mts
Normal file → Executable file
234
dev_scripts/node_ver.mjs → scripts/dev/node_ver.mts
Normal file → Executable file
|
|
@ -1,37 +1,78 @@
|
|||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { defineCommand, defineOptions } from "@robingenz/zli";
|
||||
import process from "node:process";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
confirmPrompt,
|
||||
createCliConfig,
|
||||
GITHUB_USER_AGENT,
|
||||
ROOT_DIR,
|
||||
commandExists,
|
||||
confirmPrompt,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
readNodeMajorVersion,
|
||||
runCli,
|
||||
runMain,
|
||||
run,
|
||||
runCaptureOutput,
|
||||
tryRun,
|
||||
usage,
|
||||
} from "./lib.mjs";
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type ReferenceSpec = {
|
||||
path: string;
|
||||
replacements: Array<[RegExp, string]>;
|
||||
};
|
||||
|
||||
type NodeRegistryPackage = {
|
||||
versions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AptRunner = (args: string[]) => void;
|
||||
|
||||
const rootDir = ROOT_DIR;
|
||||
const usageMessage = usage(process.argv[1], "--verify | --update");
|
||||
|
||||
function parseMode(argv) {
|
||||
const mode = argv[2] ?? "";
|
||||
if (mode !== "--verify" && mode !== "--update") {
|
||||
fail(usageMessage);
|
||||
}
|
||||
function listYamlFiles(rootPath: string): string[] {
|
||||
const yamlFiles: string[] = [];
|
||||
const excludedDirs = new Set([".git", "node_modules", "dist", "coverage"]);
|
||||
|
||||
return mode;
|
||||
const walk = (directory: string) => {
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludedDirs.has(entry.name)) {
|
||||
walk(fullPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const lower = entry.name.toLowerCase();
|
||||
if (lower.endsWith(".yml") || lower.endsWith(".yaml")) {
|
||||
yamlFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootPath);
|
||||
return yamlFiles.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function buildReferenceSpecs(nodeMajor) {
|
||||
function buildReferenceSpecs(nodeMajor: string): ReferenceSpec[] {
|
||||
const yamlReferenceSpecs: ReferenceSpec[] = listYamlFiles(rootDir).map(
|
||||
(filePath) => ({
|
||||
path: filePath,
|
||||
replacements: [
|
||||
[/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`],
|
||||
[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
path: `${rootDir}/package.json`,
|
||||
|
|
@ -40,7 +81,7 @@ function buildReferenceSpecs(nodeMajor) {
|
|||
],
|
||||
},
|
||||
{
|
||||
path: `${rootDir}/dev_scripts/lib.mjs`,
|
||||
path: `${rootDir}/scripts/devopslib.mts`,
|
||||
replacements: [
|
||||
[
|
||||
/export const CURRENT_NODE_VERSION = "\d+";/g,
|
||||
|
|
@ -48,26 +89,7 @@ function buildReferenceSpecs(nodeMajor) {
|
|||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: `${rootDir}/.github/actions/setup-env/action.yml`,
|
||||
replacements: [[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`]],
|
||||
},
|
||||
{
|
||||
path: `${rootDir}/.github/workflows/ci.yml`,
|
||||
replacements: [
|
||||
[/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: `${rootDir}/.github/workflows/pr.yml`,
|
||||
replacements: [[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`]],
|
||||
},
|
||||
{
|
||||
path: `${rootDir}/.github/workflows/release.yml`,
|
||||
replacements: [
|
||||
[/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`],
|
||||
],
|
||||
},
|
||||
...yamlReferenceSpecs,
|
||||
{
|
||||
path: `${rootDir}/README.md`,
|
||||
replacements: [[/- Node\.js \d+\+/g, `- Node.js ${nodeMajor}+`]],
|
||||
|
|
@ -75,7 +97,10 @@ function buildReferenceSpecs(nodeMajor) {
|
|||
];
|
||||
}
|
||||
|
||||
function applyReplacements(content, replacements) {
|
||||
function applyReplacements(
|
||||
content: string,
|
||||
replacements: ReferenceSpec["replacements"],
|
||||
): string {
|
||||
let updated = content;
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
updated = updated.replace(pattern, replacement);
|
||||
|
|
@ -83,7 +108,7 @@ function applyReplacements(content, replacements) {
|
|||
return updated;
|
||||
}
|
||||
|
||||
function updateNodeVersionReferences(nodeMajor) {
|
||||
function updateNodeVersionReferences(nodeMajor: string): void {
|
||||
const files = buildReferenceSpecs(nodeMajor);
|
||||
let changedCount = 0;
|
||||
|
||||
|
|
@ -105,9 +130,9 @@ function updateNodeVersionReferences(nodeMajor) {
|
|||
}
|
||||
}
|
||||
|
||||
function verifyNodeVersionReferences(nodeMajor) {
|
||||
function verifyNodeVersionReferences(nodeMajor: string): void {
|
||||
const files = buildReferenceSpecs(nodeMajor);
|
||||
const drifted = [];
|
||||
const drifted: string[] = [];
|
||||
|
||||
for (const entry of files) {
|
||||
const original = readFileSync(entry.path, "utf8");
|
||||
|
|
@ -124,8 +149,8 @@ function verifyNodeVersionReferences(nodeMajor) {
|
|||
logSuccess("All Node version references are synchronized with .node_ver.");
|
||||
}
|
||||
|
||||
async function getAvailableNodeMajors() {
|
||||
let packageJson;
|
||||
async function getAvailableNodeMajors(): Promise<string[]> {
|
||||
let packageJson: NodeRegistryPackage | null = null;
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/node", {
|
||||
headers: {
|
||||
|
|
@ -140,7 +165,7 @@ async function getAvailableNodeMajors() {
|
|||
);
|
||||
}
|
||||
|
||||
packageJson = await response.json();
|
||||
packageJson = (await response.json()) as NodeRegistryPackage;
|
||||
} catch (error) {
|
||||
fail(
|
||||
`Unable to read Node.js versions from npm registry: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -152,12 +177,18 @@ async function getAvailableNodeMajors() {
|
|||
fail("Unexpected npm registry response for Node.js versions.");
|
||||
}
|
||||
|
||||
return [...new Set(versions.map((version) => String(version).split(".")[0]))]
|
||||
return [
|
||||
...new Set(
|
||||
versions
|
||||
.map((version) => String(version).split(".")[0] ?? "")
|
||||
.filter((major) => major.length > 0),
|
||||
),
|
||||
]
|
||||
.filter((major) => /^\d+$/.test(major))
|
||||
.sort((left, right) => Number(left) - Number(right));
|
||||
}
|
||||
|
||||
function runScriptWithOptionalSudo(scriptText) {
|
||||
function runScriptWithOptionalSudo(scriptText: string): void {
|
||||
if (!commandExists("bash")) {
|
||||
fail("bash is required to run the NodeSource setup script.");
|
||||
}
|
||||
|
|
@ -187,12 +218,12 @@ function runScriptWithOptionalSudo(scriptText) {
|
|||
}
|
||||
}
|
||||
|
||||
async function setupNodeSourceRepo(nodeMajor) {
|
||||
async function setupNodeSourceRepo(nodeMajor: string): Promise<void> {
|
||||
const setupUrl = `https://deb.nodesource.com/setup_${nodeMajor}.x`;
|
||||
|
||||
logInfo(`Configuring NodeSource for Node.js ${nodeMajor}.x...`);
|
||||
|
||||
let scriptText;
|
||||
let scriptText = "";
|
||||
try {
|
||||
const response = await fetch(setupUrl, {
|
||||
headers: {
|
||||
|
|
@ -221,14 +252,18 @@ async function setupNodeSourceRepo(nodeMajor) {
|
|||
runScriptWithOptionalSudo(scriptText);
|
||||
}
|
||||
|
||||
function installNodejs(nodeMajor) {
|
||||
function installNodejs(nodeMajor: string): void {
|
||||
if (!commandExists("apt-get")) {
|
||||
fail("APT is required to install Node.js packages.");
|
||||
}
|
||||
|
||||
const aptRunner = commandExists("sudo")
|
||||
? (args) => run("sudo", ["apt-get", ...args], { cwd: rootDir })
|
||||
: (args) => run("apt-get", args, { cwd: rootDir });
|
||||
const aptRunner: AptRunner = commandExists("sudo")
|
||||
? (args) => {
|
||||
run("sudo", ["apt-get", ...args], { cwd: rootDir });
|
||||
}
|
||||
: (args) => {
|
||||
run("apt-get", args, { cwd: rootDir });
|
||||
};
|
||||
|
||||
logInfo("Updating apt package index...");
|
||||
aptRunner(["update"]);
|
||||
|
|
@ -248,13 +283,13 @@ function installNodejs(nodeMajor) {
|
|||
);
|
||||
}
|
||||
|
||||
function verifyInstalledNodeMajor(nodeMajor) {
|
||||
function verifyInstalledNodeMajor(nodeMajor: string): void {
|
||||
const nodeResult = tryRun("node", ["-v"], { cwd: rootDir, stdio: "pipe" });
|
||||
if (!nodeResult.ok) {
|
||||
fail("Node.js is not installed or not available on PATH.");
|
||||
}
|
||||
|
||||
const fullNodeVersion = (nodeResult.stdout ?? "").trim();
|
||||
const fullNodeVersion = String(nodeResult.stdout ?? "").trim();
|
||||
if (!fullNodeVersion.startsWith(`v${nodeMajor}.`)) {
|
||||
fail(
|
||||
`Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`,
|
||||
|
|
@ -264,42 +299,77 @@ function verifyInstalledNodeMajor(nodeMajor) {
|
|||
logSuccess(`Installed Node.js version matches major ${nodeMajor}.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = parseMode(process.argv);
|
||||
const nodeMajor = readNodeMajorVersion(rootDir);
|
||||
const nodeVersionOptions = defineOptions(
|
||||
z
|
||||
.object({
|
||||
verify: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Verify version references and installed Node.js"),
|
||||
update: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Update version references and install Node.js"),
|
||||
})
|
||||
.refine((options) => options.verify !== options.update, {
|
||||
message: "Specify exactly one of --verify or --update.",
|
||||
}),
|
||||
{ v: "verify", u: "update" },
|
||||
);
|
||||
|
||||
if (mode === "--verify") {
|
||||
verifyNodeVersionReferences(nodeMajor);
|
||||
verifyInstalledNodeMajor(nodeMajor);
|
||||
return;
|
||||
}
|
||||
const nodeVersionCommand = defineCommand({
|
||||
description: "Verify or update Node.js version references",
|
||||
options: nodeVersionOptions,
|
||||
action: async (options) => {
|
||||
const nodeMajor = readNodeMajorVersion(rootDir);
|
||||
if (nodeMajor.length === 0) {
|
||||
fail(".node_ver must contain a valid Node.js major version.");
|
||||
}
|
||||
|
||||
if (!commandExists("apt") && !commandExists("apt-get")) {
|
||||
fail("APT is required to install Node.js packages.");
|
||||
}
|
||||
if (options.verify) {
|
||||
verifyNodeVersionReferences(nodeMajor);
|
||||
verifyInstalledNodeMajor(nodeMajor);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableMajors = await getAvailableNodeMajors();
|
||||
if (!availableMajors.includes(nodeMajor)) {
|
||||
fail(
|
||||
`Node.js major ${nodeMajor} is not listed as available. Available majors: ${availableMajors.join(", ")}`,
|
||||
if (!commandExists("apt") && !commandExists("apt-get")) {
|
||||
fail("APT is required to install Node.js packages.");
|
||||
}
|
||||
|
||||
const availableMajors = await getAvailableNodeMajors();
|
||||
if (!availableMajors.includes(nodeMajor)) {
|
||||
fail(
|
||||
`Node.js major ${nodeMajor} is not listed as available. Available majors: ${availableMajors.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`You are about to install/update Node.js for major ${nodeMajor}.x. This may break your development environment and NPM users.`,
|
||||
);
|
||||
await confirmPrompt(
|
||||
`Type "i confirm change to ${nodeMajor}" to proceed:`,
|
||||
new RegExp(`^i confirm change to ${nodeMajor}$`),
|
||||
/^n$/,
|
||||
);
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`You are about to install/update Node.js for major ${nodeMajor}.x. This may break your development environment and NPM users.`,
|
||||
);
|
||||
await confirmPrompt(
|
||||
`Type "i confirm change to ${nodeMajor} to proceed:",
|
||||
new RegExp(`^i confirm change to ${nodeMajor}$`),
|
||||
/^n$/,
|
||||
);
|
||||
updateNodeVersionReferences(nodeMajor);
|
||||
await setupNodeSourceRepo(nodeMajor);
|
||||
installNodejs(nodeMajor);
|
||||
verifyNodeVersionReferences(nodeMajor);
|
||||
},
|
||||
});
|
||||
|
||||
updateNodeVersionReferences(nodeMajor);
|
||||
await setupNodeSourceRepo(nodeMajor);
|
||||
installNodejs(nodeMajor);
|
||||
verifyNodeVersionReferences(nodeMajor);
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Verify or update Node.js version references",
|
||||
commands: {
|
||||
run: nodeVersionCommand,
|
||||
},
|
||||
defaultCommand: nodeVersionCommand,
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runCli(cliConfig);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
await runMain(main);
|
||||
455
scripts/dev/setup_devenv.mts
Executable file
455
scripts/dev/setup_devenv.mts
Executable file
|
|
@ -0,0 +1,455 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { defineCommand, defineOptions } from "@robingenz/zli";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ROOT_DIR,
|
||||
commandExists,
|
||||
createCliConfig,
|
||||
ensureDirExists,
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
path,
|
||||
readNodeMajorVersion,
|
||||
readJsonFile,
|
||||
runCli,
|
||||
runMain,
|
||||
run,
|
||||
tryRun,
|
||||
writeJsonFile,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type IdeSettings = Record<string, unknown>;
|
||||
|
||||
type AuditCounts = {
|
||||
critical: number;
|
||||
high: number;
|
||||
};
|
||||
|
||||
type SupportedIde = "vscode" | "cursor" | "windsurf" | "zed" | "jetbrains";
|
||||
|
||||
type SetupDevEnvOptions = {
|
||||
ide: SupportedIde;
|
||||
};
|
||||
|
||||
const SUPPORTED_IDES: SupportedIde[] = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"zed",
|
||||
"jetbrains",
|
||||
];
|
||||
|
||||
const IDE_TARGET_DIRS: Record<SupportedIde, string> = {
|
||||
vscode: ".vscode",
|
||||
cursor: ".cursor",
|
||||
windsurf: ".windsurf",
|
||||
zed: ".zed",
|
||||
jetbrains: ".idea",
|
||||
};
|
||||
|
||||
const IDE_SETTINGS_RELPATHS: Partial<Record<SupportedIde, string>> = {
|
||||
vscode: ".vscode/settings.json",
|
||||
cursor: ".cursor/settings.json",
|
||||
windsurf: ".windsurf/settings.json",
|
||||
zed: ".zed/settings.json",
|
||||
};
|
||||
|
||||
const AGENTS_MD_SETTING_KEY = "chat.useAgentsMdFile";
|
||||
const AGENTS_MD_SUPPORTED_IDES: ReadonlySet<SupportedIde> = new Set([
|
||||
"vscode",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
]);
|
||||
|
||||
type JsonPrimitive = null | boolean | number | string;
|
||||
interface JsonArray extends Array<JsonLike> {}
|
||||
interface JsonRecord {
|
||||
[key: string]: JsonLike;
|
||||
}
|
||||
type JsonLike = JsonPrimitive | JsonArray | JsonRecord;
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeJsonArrays(
|
||||
existing: JsonLike[],
|
||||
template: JsonLike[],
|
||||
): JsonLike[] {
|
||||
const merged = [...existing];
|
||||
const seen = new Set(existing.map((item) => JSON.stringify(item)));
|
||||
|
||||
for (const item of template) {
|
||||
const key = JSON.stringify(item);
|
||||
if (!seen.has(key)) {
|
||||
merged.push(item);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeJsonWithTemplate(
|
||||
existing: JsonLike,
|
||||
template: JsonLike,
|
||||
): JsonLike {
|
||||
if (Array.isArray(existing) && Array.isArray(template)) {
|
||||
return mergeJsonArrays(existing, template);
|
||||
}
|
||||
|
||||
if (isJsonRecord(existing) && isJsonRecord(template)) {
|
||||
const merged: JsonRecord = { ...existing };
|
||||
|
||||
for (const [key, templateValue] of Object.entries(template)) {
|
||||
const existingValue = merged[key];
|
||||
if (existingValue === undefined) {
|
||||
merged[key] = templateValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[key] = mergeJsonWithTemplate(existingValue, templateValue);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// For scalar/shape mismatches, template wins.
|
||||
return template;
|
||||
}
|
||||
|
||||
function applyIdeTemplate(ide: SupportedIde): void {
|
||||
const templateDir = path.join(
|
||||
ROOT_DIR,
|
||||
"scripts",
|
||||
"dev",
|
||||
"ide_templates",
|
||||
ide,
|
||||
);
|
||||
if (!existsSync(templateDir)) {
|
||||
fail(`IDE template directory does not exist: ${templateDir}`);
|
||||
}
|
||||
|
||||
const targetDir = path.join(ROOT_DIR, IDE_TARGET_DIRS[ide]);
|
||||
ensureDirExists(targetDir);
|
||||
|
||||
const entries = readdirSync(templateDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const templatePath = path.join(templateDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (!entry.name.endsWith(".json")) {
|
||||
if (!existsSync(targetPath)) {
|
||||
writeFileSync(targetPath, readFileSync(templatePath, "utf8"), "utf8");
|
||||
logInfo(`Applied template file ${targetPath}`);
|
||||
} else {
|
||||
logInfo(`Skipped existing non-JSON file ${targetPath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const templateJson = readJsonFile(templatePath) as JsonLike;
|
||||
if (!existsSync(targetPath)) {
|
||||
writeJsonFile(targetPath, templateJson);
|
||||
logInfo(`Applied JSON template ${targetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingJson = readJsonFile(targetPath) as JsonLike;
|
||||
const mergedJson = mergeJsonWithTemplate(existingJson, templateJson);
|
||||
writeJsonFile(targetPath, mergedJson);
|
||||
logInfo(`Merged JSON template into ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getIdeSettingsPath(ide: SupportedIde): string | null {
|
||||
const relPath = IDE_SETTINGS_RELPATHS[ide];
|
||||
if (!relPath) {
|
||||
return null;
|
||||
}
|
||||
return path.join(ROOT_DIR, relPath);
|
||||
}
|
||||
|
||||
function ensureIdeSettingsFile(ide: SupportedIde): string | null {
|
||||
const ideSettingsPath = getIdeSettingsPath(ide);
|
||||
if (!ideSettingsPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ideSettingsDir = path.dirname(ideSettingsPath);
|
||||
ensureDirExists(ideSettingsDir);
|
||||
|
||||
const templateSettingsPath = path.join(
|
||||
ROOT_DIR,
|
||||
"scripts",
|
||||
"dev",
|
||||
"ide_templates",
|
||||
ide,
|
||||
"settings.json",
|
||||
);
|
||||
|
||||
if (!existsSync(templateSettingsPath)) {
|
||||
if (!existsSync(ideSettingsPath)) {
|
||||
writeJsonFile(ideSettingsPath, {});
|
||||
logWarn(`Created missing ${ideSettingsPath}`);
|
||||
}
|
||||
return ideSettingsPath;
|
||||
}
|
||||
|
||||
const templateSettingsJson = readJsonFile(templateSettingsPath) as JsonLike;
|
||||
|
||||
if (!existsSync(ideSettingsPath)) {
|
||||
writeJsonFile(ideSettingsPath, templateSettingsJson);
|
||||
logWarn(`Created missing ${ideSettingsPath}`);
|
||||
return ideSettingsPath;
|
||||
}
|
||||
|
||||
const existingSettingsJson = readJsonFile(ideSettingsPath) as JsonLike;
|
||||
const mergedSettingsJson = mergeJsonWithTemplate(
|
||||
existingSettingsJson,
|
||||
templateSettingsJson,
|
||||
);
|
||||
writeJsonFile(ideSettingsPath, mergedSettingsJson);
|
||||
|
||||
return ideSettingsPath;
|
||||
}
|
||||
|
||||
function ensureAgentsFilesUsed(ide: SupportedIde): void {
|
||||
if (!AGENTS_MD_SUPPORTED_IDES.has(ide)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ideSettingsPath = getIdeSettingsPath(ide);
|
||||
if (!ideSettingsPath || !existsSync(ideSettingsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ideSettingsJson = readJsonFile(ideSettingsPath) as IdeSettings;
|
||||
|
||||
if (!ideSettingsJson[AGENTS_MD_SETTING_KEY]) {
|
||||
ideSettingsJson[AGENTS_MD_SETTING_KEY] = true;
|
||||
writeJsonFile(ideSettingsPath, ideSettingsJson);
|
||||
logWarn(
|
||||
`Updated ${ideSettingsPath} to enable chat.useAgentsMdFile for AGENTS.md support.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAptDependencies(): void {
|
||||
if (!commandExists("apt-get")) {
|
||||
fail("apt-get is required for system dependency installation.");
|
||||
}
|
||||
|
||||
const aptPackages: Array<[string, string]> = [
|
||||
["curl", "curl"],
|
||||
["gh", "gh"],
|
||||
["git", "git"],
|
||||
["jq", "jq"],
|
||||
["flock", "util-linux"],
|
||||
["node-typescript", "node-typescript"],
|
||||
["shellcheck", "shellcheck"],
|
||||
];
|
||||
|
||||
const missingPackages = [
|
||||
...new Set(
|
||||
aptPackages
|
||||
.filter(([command]) => !commandExists(command))
|
||||
.map(([, packageName]) => packageName),
|
||||
),
|
||||
];
|
||||
|
||||
if (missingPackages.length === 0) {
|
||||
logSuccess("All required apt CLI dependencies are already installed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const aptRunner: (args: string[]) => void = commandExists("sudo")
|
||||
? (args) => run("sudo", ["apt-get", ...args])
|
||||
: (args) => run("apt-get", args);
|
||||
|
||||
logInfo(`Installing missing apt packages: ${missingPackages.join(" ")}`);
|
||||
aptRunner(["update"]);
|
||||
aptRunner(["install", "-y", ...missingPackages]);
|
||||
}
|
||||
|
||||
function ensureNodeDependencies(nodeVersionMajor: string): void {
|
||||
if (!commandExists("npm")) {
|
||||
fail(
|
||||
`npm is required but not installed. Install Node.js >=${nodeVersionMajor} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fast deterministic install path aligned with lockfile-based CI behavior.
|
||||
logInfo(
|
||||
"Installing npm dependencies (including devDependencies) via npm ci...",
|
||||
);
|
||||
run("npm", ["ci", "--include=dev"]);
|
||||
}
|
||||
|
||||
function warnIfDockerMissing(): void {
|
||||
if (commandExists("docker")) {
|
||||
logSuccess(
|
||||
"Docker CLI detected for devcontainer-backed integration tests.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
"Docker is not installed. Devcontainer-backed integration tests will be unavailable until Docker is installed.",
|
||||
);
|
||||
}
|
||||
|
||||
function parseAuditJson(outputText: string): {
|
||||
metadata?: { vulnerabilities?: Record<string, unknown> };
|
||||
} {
|
||||
if (!outputText || outputText.trim().length === 0) {
|
||||
fail("npm audit produced empty output.");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(outputText);
|
||||
} catch {
|
||||
const firstBraceIndex = outputText.indexOf("{");
|
||||
const lastBraceIndex = outputText.lastIndexOf("}");
|
||||
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
||||
return JSON.parse(outputText.slice(firstBraceIndex, lastBraceIndex + 1));
|
||||
}
|
||||
fail("Unable to parse npm audit JSON output.");
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
function getAuditCounts(): AuditCounts {
|
||||
const audit = tryRun("npm", ["audit", "--json"], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const report = parseAuditJson(String(audit.stdout ?? ""));
|
||||
const vulnerabilities = report?.metadata?.vulnerabilities ?? {};
|
||||
|
||||
return {
|
||||
critical: Number(vulnerabilities.critical ?? 0),
|
||||
high: Number(vulnerabilities.high ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function runAuditFix(force = false): void {
|
||||
const args = force ? ["audit", "fix", "--force"] : ["audit", "fix"];
|
||||
const result = tryRun("npm", args, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
logWarn(
|
||||
`npm ${args.join(" ")} exited with code ${result.status ?? "unknown"}. Continuing to verification...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAuditRemediation(): void {
|
||||
let counts = getAuditCounts();
|
||||
logInfo(`Audit baseline: high=${counts.high}, critical=${counts.critical}.`);
|
||||
|
||||
if (counts.high === 0 && counts.critical === 0) {
|
||||
logSuccess("No high/critical npm vulnerabilities detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`Detected vulnerabilities: high=${counts.high}, critical=${counts.critical}. Running npm audit fix...`,
|
||||
);
|
||||
runAuditFix(false);
|
||||
counts = getAuditCounts();
|
||||
logInfo(
|
||||
`Audit after npm audit fix: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
|
||||
if (counts.high === 0 && counts.critical === 0) {
|
||||
logSuccess(
|
||||
"High/critical npm vulnerabilities remediated with npm audit fix.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`High/critical vulnerabilities remain: high=${counts.high}, critical=${counts.critical}. Running npm audit fix --force...`,
|
||||
);
|
||||
runAuditFix(true);
|
||||
counts = getAuditCounts();
|
||||
logInfo(
|
||||
`Audit after npm audit fix --force: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
|
||||
if (counts.high > 0 || counts.critical > 0) {
|
||||
fail(
|
||||
`Unable to remediate all high/critical vulnerabilities: high=${counts.high}, critical=${counts.critical}.`,
|
||||
);
|
||||
}
|
||||
|
||||
logSuccess("High/critical npm vulnerabilities remediated.");
|
||||
}
|
||||
|
||||
async function main(options: SetupDevEnvOptions): Promise<void> {
|
||||
const nodeVersionMajor = readNodeMajorVersion();
|
||||
|
||||
logInfo(`Applying IDE template for '${options.ide}'...`);
|
||||
applyIdeTemplate(options.ide);
|
||||
|
||||
logInfo(`Ensuring ${options.ide} settings file is present and aligned...`);
|
||||
ensureIdeSettingsFile(options.ide);
|
||||
if (AGENTS_MD_SUPPORTED_IDES.has(options.ide)) {
|
||||
logInfo(`Ensuring chat.useAgentsMdFile is enabled for ${options.ide}...`);
|
||||
ensureAgentsFilesUsed(options.ide);
|
||||
}
|
||||
logInfo("Ensuring required APT dependencies are installed...");
|
||||
ensureAptDependencies();
|
||||
logInfo("Checking optional Docker support...");
|
||||
logInfo(
|
||||
`Setting up development environment for Node.js v${nodeVersionMajor}.x...`,
|
||||
);
|
||||
ensureNodeDependencies(nodeVersionMajor);
|
||||
ensureAuditRemediation();
|
||||
warnIfDockerMissing();
|
||||
logSuccess("Development environment setup complete.");
|
||||
}
|
||||
|
||||
const setupDevenvCommand = defineCommand({
|
||||
description: "Set up local development environment",
|
||||
options: defineOptions(
|
||||
z.object({
|
||||
ide: z
|
||||
.enum(SUPPORTED_IDES as [SupportedIde, ...SupportedIde[]])
|
||||
.default("vscode")
|
||||
.describe("IDE template to apply before dependency setup"),
|
||||
}),
|
||||
{
|
||||
i: "ide",
|
||||
},
|
||||
),
|
||||
action: async (options: SetupDevEnvOptions) => {
|
||||
await main(options);
|
||||
},
|
||||
});
|
||||
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Set up local development environment",
|
||||
commands: {
|
||||
run: setupDevenvCommand,
|
||||
},
|
||||
defaultCommand: setupDevenvCommand,
|
||||
});
|
||||
|
||||
await runMain(async () => {
|
||||
await runCli(cliConfig);
|
||||
});
|
||||
415
scripts/devopslib.mts
Executable file
415
scripts/devopslib.mts
Executable file
|
|
@ -0,0 +1,415 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
execFileSync,
|
||||
spawnSync,
|
||||
type ExecFileSyncOptions,
|
||||
type SpawnSyncOptions,
|
||||
} from "node:child_process";
|
||||
import {
|
||||
defineConfig,
|
||||
processConfig,
|
||||
type CommandDefinition,
|
||||
type DefineConfig,
|
||||
type ProcessResult,
|
||||
} from "@robingenz/zli";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export { fileURLToPath, path, process };
|
||||
|
||||
export class Paths {
|
||||
importMetaUrl: string;
|
||||
|
||||
constructor(importMetaUrl: string) {
|
||||
this.importMetaUrl = importMetaUrl;
|
||||
}
|
||||
|
||||
scriptDir() {
|
||||
return path.dirname(fileURLToPath(this.importMetaUrl));
|
||||
}
|
||||
|
||||
repoRootDir() {
|
||||
return path.resolve(this.scriptDir(), "..");
|
||||
}
|
||||
}
|
||||
|
||||
export const ROOT_DIR = new Paths(import.meta.url).repoRootDir();
|
||||
|
||||
export const REPO_OWNER = "awalsh128";
|
||||
export const REPO_NAME = "cache-apt-pkgs-action";
|
||||
export const REPO_SLUG = `${REPO_OWNER}/${REPO_NAME}`;
|
||||
export const REPO_URL = `https://github.com/${REPO_SLUG}.git`;
|
||||
export const GITHUB_API_BASE_URL = "https://api.github.com";
|
||||
export const GITHUB_USER_AGENT = `${REPO_NAME}-dev-scripts`;
|
||||
|
||||
export const VSCODE_SETTINGS_RELPATH = ".vscode/settings.json";
|
||||
export const VSCODE_SETTINGS_DEFAULTS = {
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"*": true,
|
||||
},
|
||||
"chat.useAgentsMdFile": true,
|
||||
};
|
||||
|
||||
export const CURRENT_NODE_VERSION = "24";
|
||||
|
||||
export function usage(scriptPath: string, params: string) {
|
||||
return `usage: ${path.basename(scriptPath)} ${params}`;
|
||||
}
|
||||
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
} as const;
|
||||
|
||||
export type COLOR = (typeof COLORS)[keyof typeof COLORS];
|
||||
|
||||
export type LogParams = {
|
||||
color?: COLOR;
|
||||
indent?: number;
|
||||
};
|
||||
|
||||
export function fail(message: string, exitCode = 1, params: LogParams = {}) {
|
||||
logError(message, params);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
export function log(message: string, params: LogParams = {}) {
|
||||
console.log(
|
||||
`${" ".repeat(params.indent ?? 0)}${params.color ?? ""}${message}${COLORS.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function logInfo(message: string, params: LogParams = {}) {
|
||||
log(`ℹ️ ${message}`, params);
|
||||
}
|
||||
|
||||
export function logSuccess(message: string, params: LogParams = {}) {
|
||||
log(`✅ ${message}`, params);
|
||||
}
|
||||
|
||||
export function logWarn(message: string, params: LogParams = {}) {
|
||||
log(`⚠️ ${message}`, params);
|
||||
}
|
||||
|
||||
export function logError(message: string, params: LogParams = {}) {
|
||||
log(`❌ ${message}`, params);
|
||||
}
|
||||
|
||||
export function assertNonEmpty(
|
||||
value: string,
|
||||
message: string,
|
||||
usageMessage?: string,
|
||||
) {
|
||||
if (!value || value.trim().length === 0) {
|
||||
if (usageMessage) {
|
||||
console.error(usageMessage);
|
||||
}
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string) {
|
||||
const isExecutable = (candidatePath: string) => {
|
||||
try {
|
||||
accessSync(candidatePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (command.includes("/")) {
|
||||
return isExecutable(command);
|
||||
}
|
||||
|
||||
const pathValue = process.env.PATH ?? "";
|
||||
const dirs = pathValue.split(path.delimiter).filter(Boolean);
|
||||
return dirs.some((dirPath: string) =>
|
||||
isExecutable(path.join(dirPath, command)),
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureDirExists(dirPath: string) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
export function readJsonFile(filePath: string) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
export function writeJsonFile(filePath: string, value: unknown) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function readNodeMajorVersion(rootDir = ROOT_DIR): string {
|
||||
const nodeVersionPath = path.join(rootDir, ".node_ver");
|
||||
|
||||
let fileText: string = "";
|
||||
try {
|
||||
fileText = readFileSync(nodeVersionPath, "utf8");
|
||||
} catch (error) {
|
||||
fail(
|
||||
`Missing or unreadable version file at ${nodeVersionPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines = fileText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (lines.length !== 1) {
|
||||
fail(".node_ver must contain exactly one non-empty line.");
|
||||
}
|
||||
|
||||
const rawVersion = lines[0] ?? "";
|
||||
if (rawVersion.length === 0) {
|
||||
fail(".node_ver must contain exactly one non-empty line.");
|
||||
}
|
||||
if (!/^\d+$/.test(rawVersion)) {
|
||||
fail(
|
||||
".node_ver must contain only the Node.js major version (e.g. 20, 24).",
|
||||
);
|
||||
}
|
||||
|
||||
return rawVersion;
|
||||
}
|
||||
|
||||
function normalizeExecOptions(
|
||||
optionsOrQuiet: boolean | ExecFileSyncOptions | undefined,
|
||||
): ExecFileSyncOptions {
|
||||
if (typeof optionsOrQuiet === "boolean") {
|
||||
return {
|
||||
cwd: ROOT_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: optionsOrQuiet ? "pipe" : "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
const options = optionsOrQuiet ?? {};
|
||||
return {
|
||||
cwd: options.cwd ?? ROOT_DIR,
|
||||
env: options.env ?? process.env,
|
||||
encoding: options.encoding ?? "utf8",
|
||||
maxBuffer: options.maxBuffer,
|
||||
killSignal: options.killSignal,
|
||||
shell: options.shell,
|
||||
uid: options.uid,
|
||||
gid: options.gid,
|
||||
timeout: options.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpawnOptions(
|
||||
optionsOrQuiet: boolean | SpawnSyncOptions | undefined,
|
||||
forcePipe = false,
|
||||
): SpawnSyncOptions {
|
||||
if (typeof optionsOrQuiet === "boolean") {
|
||||
return {
|
||||
cwd: ROOT_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: forcePipe ? "pipe" : optionsOrQuiet ? "pipe" : "inherit",
|
||||
} satisfies SpawnSyncOptions;
|
||||
}
|
||||
|
||||
const options = optionsOrQuiet ?? {};
|
||||
return {
|
||||
cwd: options.cwd ?? ROOT_DIR,
|
||||
env: options.env ?? process.env,
|
||||
encoding: options.encoding ?? "utf8",
|
||||
stdio: forcePipe ? "pipe" : (options.stdio ?? "inherit"),
|
||||
} satisfies SpawnSyncOptions;
|
||||
}
|
||||
|
||||
export function run(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
optionsOrQuiet: boolean | ExecFileSyncOptions | undefined = undefined,
|
||||
) {
|
||||
const options = normalizeExecOptions(optionsOrQuiet);
|
||||
try {
|
||||
return execFileSync(command, args, options);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to execute '${command} ${args.join(" ")}'. ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function tryRun(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
optionsOrQuiet: boolean | SpawnSyncOptions | undefined = undefined,
|
||||
) {
|
||||
const options = normalizeSpawnOptions(optionsOrQuiet);
|
||||
const result = spawnSync(command, args, options);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function runCaptureOutput(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
options: boolean | SpawnSyncOptions | undefined = undefined,
|
||||
) {
|
||||
const normalizedOptions = normalizeSpawnOptions(options, true);
|
||||
const result = spawnSync(command, args, normalizedOptions);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Command '${command} ${args.join(" ")}' exited with status ${result.status}:\n${String(result.stderr ?? "").trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return String(result.stdout ?? "").trim();
|
||||
}
|
||||
|
||||
type CliAction = (
|
||||
options: unknown,
|
||||
args: string[],
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
type CliResult = ProcessResult<CommandDefinition<any, any>>;
|
||||
|
||||
type CliCommands = Record<string, CommandDefinition<any, any>>;
|
||||
|
||||
type CliConfig = DefineConfig<CliCommands>;
|
||||
|
||||
type CliConfigParams = {
|
||||
name?: string;
|
||||
importMetaUrl?: string;
|
||||
description: string;
|
||||
commands: CliCommands;
|
||||
defaultCommand?: CommandDefinition<any, any>;
|
||||
};
|
||||
|
||||
type CliResultCommand = {
|
||||
command: {
|
||||
action: CliAction;
|
||||
};
|
||||
options: unknown;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function createCliConfig(params: CliConfigParams) {
|
||||
const derivedName = (() => {
|
||||
if (params.name && params.name.trim().length > 0) {
|
||||
return params.name;
|
||||
}
|
||||
|
||||
if (params.importMetaUrl && params.importMetaUrl.trim().length > 0) {
|
||||
const filePath = fileURLToPath(params.importMetaUrl);
|
||||
const parsed = path.parse(filePath);
|
||||
if (parsed.name.trim().length > 0) {
|
||||
return parsed.name;
|
||||
}
|
||||
}
|
||||
|
||||
fail(
|
||||
"createCliConfig requires either a non-empty 'name' or a valid 'importMetaUrl'.",
|
||||
);
|
||||
})();
|
||||
|
||||
return defineConfig({
|
||||
meta: {
|
||||
name: derivedName,
|
||||
version: process.env.npm_package_version ?? "0.0.0",
|
||||
description: params.description,
|
||||
},
|
||||
commands: params.commands,
|
||||
defaultCommand: params.defaultCommand,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCli(
|
||||
cliConfig: CliConfig,
|
||||
argv: string[] = process.argv.slice(2),
|
||||
): Promise<void> {
|
||||
const result = processConfig(cliConfig, argv) as CliResult;
|
||||
const commandResult = result as unknown as CliResultCommand;
|
||||
await commandResult.command.action(commandResult.options, commandResult.args);
|
||||
}
|
||||
|
||||
export async function runMain(
|
||||
action: () => void | Promise<void>,
|
||||
exitCode = 1,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error), exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmPrompt(
|
||||
message: string,
|
||||
yesPattern: RegExp = /^[yY]$/,
|
||||
noPattern: RegExp = /^[nN]$/,
|
||||
) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
const getText = (pattern: RegExp) =>
|
||||
pattern.source
|
||||
.trim()
|
||||
.replace(/^\^/, "")
|
||||
.replace(/\$$/, "")
|
||||
.replace(/\\([^\\])/g, "$1");
|
||||
while (true) {
|
||||
const answer = await rl
|
||||
.question(
|
||||
`${message} [${getText(yesPattern)} | ${getText(noPattern)}] `,
|
||||
)
|
||||
.then((ans: string) => ans.trim().toLowerCase());
|
||||
if (yesPattern.test(answer) || answer.trim() === "") {
|
||||
return;
|
||||
}
|
||||
if (noPattern.test(answer)) {
|
||||
fail("Aborted.", 0);
|
||||
}
|
||||
logError(
|
||||
`Invalid option '${answer}' selected. Options are: ${getText(yesPattern)} or ${getText(noPattern)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
142
dev_scripts/verify_configs.mjs → scripts/ops/check_configs.mts
Normal file → Executable file
142
dev_scripts/verify_configs.mjs → scripts/ops/check_configs.mts
Normal file → Executable file
|
|
@ -1,22 +1,31 @@
|
|||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import {
|
||||
ROOT_DIR,
|
||||
fail,
|
||||
logError,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
runCaptureOutput,
|
||||
logError,
|
||||
readJsonFile,
|
||||
ROOT_DIR,
|
||||
REPO_SLUG,
|
||||
} from "./lib.mjs";
|
||||
} from "../devopslib.mts";
|
||||
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import vscodeSettingsJson from "../.vscode/settings.json" with { type: "json" };
|
||||
import tsconfigJson from "../tsconfig.json" with { type: "json" };
|
||||
import typedocJson from "../typedoc.json" with { type: "json" };
|
||||
type ErrorCollector = {
|
||||
errors: string[];
|
||||
add: (message: string) => void;
|
||||
};
|
||||
|
||||
function tokenizePath(pathExpression) {
|
||||
const tokens = [];
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const packageJson = readJsonFile(`${ROOT_DIR}/package.json`) as JsonRecord;
|
||||
const vscodeSettingsJson = readJsonFile(
|
||||
`${ROOT_DIR}/.vscode/settings.json`,
|
||||
) as JsonRecord;
|
||||
const tsconfigJson = readJsonFile(`${ROOT_DIR}/tsconfig.json`) as JsonRecord;
|
||||
const typedocJson = readJsonFile(`${ROOT_DIR}/typedoc.json`) as JsonRecord;
|
||||
|
||||
function tokenizePath(pathExpression: string): Array<string | number> {
|
||||
const tokens: Array<string | number> = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < pathExpression.length) {
|
||||
|
|
@ -59,7 +68,10 @@ function tokenizePath(pathExpression) {
|
|||
return tokens;
|
||||
}
|
||||
|
||||
function getByPath(root, pathExpression) {
|
||||
function getByPath(
|
||||
root: unknown,
|
||||
pathExpression: string,
|
||||
): { found: boolean; value: unknown } {
|
||||
const tokens = tokenizePath(pathExpression);
|
||||
let cursor = root;
|
||||
|
||||
|
|
@ -80,23 +92,23 @@ function getByPath(root, pathExpression) {
|
|||
return { found: false, value: undefined };
|
||||
}
|
||||
|
||||
cursor = cursor[token];
|
||||
cursor = (cursor as Record<string, unknown>)[token];
|
||||
}
|
||||
|
||||
return { found: true, value: cursor };
|
||||
}
|
||||
|
||||
function createErrorCollector(fileLabel) {
|
||||
const errors = [];
|
||||
function createErrorCollector(fileLabel: string): ErrorCollector {
|
||||
const errors: string[] = [];
|
||||
return {
|
||||
errors,
|
||||
add(message) {
|
||||
add(message: string) {
|
||||
errors.push(`${fileLabel}: ${message}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function valueType(value) {
|
||||
function valueType(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return "array";
|
||||
}
|
||||
|
|
@ -108,7 +120,12 @@ function valueType(value) {
|
|||
return typeof value;
|
||||
}
|
||||
|
||||
function expectFieldType(doc, fieldPath, expectedType, addError) {
|
||||
function expectFieldType(
|
||||
doc: unknown,
|
||||
fieldPath: string,
|
||||
expectedType: string,
|
||||
addError: (message: string) => void,
|
||||
) {
|
||||
const result = getByPath(doc, fieldPath);
|
||||
if (!result.found) {
|
||||
addError(`missing required field '${fieldPath}'`);
|
||||
|
|
@ -124,11 +141,11 @@ function expectFieldType(doc, fieldPath, expectedType, addError) {
|
|||
}
|
||||
|
||||
function expectFieldValue(
|
||||
doc,
|
||||
fieldPath,
|
||||
expectedValue,
|
||||
addError,
|
||||
errorMessage,
|
||||
doc: unknown,
|
||||
fieldPath: string,
|
||||
expectedValue: unknown,
|
||||
addError: (message: string) => void,
|
||||
errorMessage?: string,
|
||||
) {
|
||||
const result = getByPath(doc, fieldPath);
|
||||
if (!result.found) {
|
||||
|
|
@ -143,18 +160,11 @@ function expectFieldValue(
|
|||
}
|
||||
}
|
||||
|
||||
function validatePackageJson(json) {
|
||||
function validatePackageJson(json: JsonRecord): string[] {
|
||||
const { errors, add } = createErrorCollector("package.json");
|
||||
logInfo("Validating package.json...");
|
||||
|
||||
expectFieldValue(json, "name", "cache-apt-pkgs-action", add);
|
||||
expectFieldValue(
|
||||
json,
|
||||
"version",
|
||||
"0.0.0-semantically-released",
|
||||
add,
|
||||
"version is updated automatically by semantic-release and should not be changed manually",
|
||||
);
|
||||
expectFieldValue(json, "name", "ts-apt", add);
|
||||
expectFieldValue(json, "license", "Apache-2.0", add);
|
||||
expectFieldValue(json, "repository.type", "git", add);
|
||||
expectFieldValue(
|
||||
|
|
@ -167,7 +177,7 @@ function validatePackageJson(json) {
|
|||
return errors;
|
||||
}
|
||||
|
||||
function validateTsconfigJson(json) {
|
||||
function validateTsconfigJson(json: JsonRecord): string[] {
|
||||
logInfo("Validating tsconfig.json...");
|
||||
const { errors, add } = createErrorCollector("tsconfig.json");
|
||||
|
||||
|
|
@ -177,7 +187,7 @@ function validateTsconfigJson(json) {
|
|||
return errors;
|
||||
}
|
||||
|
||||
function validateTypedocJson(json) {
|
||||
function validateTypedocJson(json: JsonRecord): string[] {
|
||||
logInfo("Validating typedoc.json...");
|
||||
const { errors, add } = createErrorCollector("typedoc.json");
|
||||
|
||||
|
|
@ -187,54 +197,29 @@ function validateTypedocJson(json) {
|
|||
return errors;
|
||||
}
|
||||
|
||||
function validateVscodeSettingsJson(json) {
|
||||
function validateVscodeSettingsJson(json: JsonRecord): string[] {
|
||||
logInfo("Validating .vscode/settings.json...");
|
||||
const { errors, add } = createErrorCollector(".vscode/settings.json");
|
||||
|
||||
expectFieldType(json, "chat.tools.terminal.autoApprove", "object", add);
|
||||
expectFieldValue(json, "chat.useAgentsMdFile", true, add);
|
||||
const autoApprove = json["chat.tools.terminal.autoApprove"];
|
||||
if (
|
||||
autoApprove === undefined ||
|
||||
autoApprove === null ||
|
||||
typeof autoApprove !== "object" ||
|
||||
Array.isArray(autoApprove)
|
||||
) {
|
||||
add("missing required field 'chat.tools.terminal.autoApprove'");
|
||||
}
|
||||
|
||||
const useAgentsMdFile = json["chat.useAgentsMdFile"];
|
||||
if (useAgentsMdFile !== true) {
|
||||
add("missing required field 'chat.useAgentsMdFile'");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function runGitNameOnly(args, cwd) {
|
||||
const output = runCaptureOutput("git", args, {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function getProhibitedPathsChanges() {
|
||||
const prohibitedPaths = ["docs/api"];
|
||||
const staged = runGitNameOnly(
|
||||
["diff", "--name-only", "--cached", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
const unstaged = runGitNameOnly(
|
||||
["diff", "--name-only", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
const untracked = runGitNameOnly(
|
||||
["ls-files", "--others", "--exclude-standard", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
|
||||
return [...new Set([...staged, ...unstaged, ...untracked])].sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
function main(): void {
|
||||
const errors = [
|
||||
...validatePackageJson(packageJson),
|
||||
...validateTsconfigJson(tsconfigJson),
|
||||
|
|
@ -242,13 +227,6 @@ function main() {
|
|||
...validateVscodeSettingsJson(vscodeSettingsJson),
|
||||
];
|
||||
|
||||
const changedPaths = getProhibitedPathsChanges();
|
||||
if (changedPaths.length > 0) {
|
||||
errors.push(
|
||||
`git-change-check: detected changes in prohibited paths (docs/api): ${changedPaths.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logError(`Found ${errors.length} validation issue(s):`);
|
||||
for (const errorText of errors) {
|
||||
358
scripts/ops/check_latest_action_pin.mts
Executable file
358
scripts/ops/check_latest_action_pin.mts
Executable file
|
|
@ -0,0 +1,358 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { defineCommand, defineOptions } from "@robingenz/zli";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
loadActionRepoData,
|
||||
parseActionRepoSlug,
|
||||
resolveActionRefSha,
|
||||
} from "./opslib.mts";
|
||||
import {
|
||||
COLORS,
|
||||
createCliConfig,
|
||||
fail,
|
||||
GITHUB_API_BASE_URL,
|
||||
GITHUB_USER_AGENT,
|
||||
log,
|
||||
logError,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
logWarn,
|
||||
runCli,
|
||||
runMain,
|
||||
runCaptureOutput,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type ActionPin = {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
actionPath: string;
|
||||
currentRef: string;
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
tag_name: string;
|
||||
html_url?: string;
|
||||
};
|
||||
|
||||
type RepoData = {
|
||||
tagMap: Map<string, string>;
|
||||
latestRelease: GitHubRelease | null;
|
||||
releases: GitHubRelease[];
|
||||
};
|
||||
|
||||
type ActionAnalysis = {
|
||||
actionPath: string;
|
||||
repoSlug: string;
|
||||
currentRef: string;
|
||||
currentSha: string;
|
||||
currentRelease: GitHubRelease | null;
|
||||
latestRelease: GitHubRelease | null;
|
||||
latestSha: string | null;
|
||||
notPinned: boolean;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
async function fetchJson(url: string): Promise<unknown | null> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": GITHUB_USER_AGENT,
|
||||
};
|
||||
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`GitHub API request failed (${response.status}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getLatestRelease(
|
||||
repoSlug: string,
|
||||
): Promise<GitHubRelease | null> {
|
||||
const release = await fetchJson(
|
||||
`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases/latest`,
|
||||
);
|
||||
return release && typeof release === "object"
|
||||
? (release as GitHubRelease)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function getReleases(repoSlug: string): Promise<GitHubRelease[]> {
|
||||
const releases = await fetchJson(
|
||||
`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases?per_page=100`,
|
||||
);
|
||||
return Array.isArray(releases) ? releases : [];
|
||||
}
|
||||
|
||||
function findReleaseBySha(
|
||||
releases: GitHubRelease[],
|
||||
tagMap: Map<string, string>,
|
||||
sha: string,
|
||||
): GitHubRelease | null {
|
||||
return (
|
||||
releases.find(
|
||||
(release) => tagMap.get(release.tag_name)?.toLowerCase() === sha,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function walkFiles(rootDir: string): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.endsWith(".yml") || entry.name.endsWith(".yaml")) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function findActionPins(rootDir: string): ActionPin[] {
|
||||
const results: ActionPin[] = [];
|
||||
const usesPattern = /^\s*uses:\s*["']?([^"'\s#]+)["']?/;
|
||||
|
||||
for (const filePath of walkFiles(rootDir)) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const match = usesPattern.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spec = match[1] ?? "";
|
||||
if (
|
||||
!spec.includes("@") ||
|
||||
spec.startsWith("./") ||
|
||||
spec.startsWith("docker://")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const atIndex = spec.lastIndexOf("@");
|
||||
const actionPath = spec.slice(0, atIndex);
|
||||
const currentRef = spec.slice(atIndex + 1);
|
||||
if (!actionPath || !currentRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
filePath,
|
||||
lineNumber: index + 1,
|
||||
actionPath,
|
||||
currentRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const repoCache = new Map<string, Promise<RepoData>>();
|
||||
|
||||
async function getRepoData(repoSlug: string): Promise<RepoData> {
|
||||
const cached = repoCache.get(repoSlug);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<RepoData> => {
|
||||
const { tagToSha: tagMap } = loadActionRepoData(repoSlug, process.cwd());
|
||||
const [latestRelease, releases] = await Promise.all([
|
||||
getLatestRelease(repoSlug),
|
||||
getReleases(repoSlug),
|
||||
]);
|
||||
return { tagMap, latestRelease, releases };
|
||||
})();
|
||||
|
||||
repoCache.set(repoSlug, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function analyzeAction(
|
||||
actionPath: string,
|
||||
currentRef: string,
|
||||
): Promise<ActionAnalysis> {
|
||||
const repoSlug = parseActionRepoSlug(actionPath);
|
||||
const { tagMap, latestRelease, releases } = await getRepoData(repoSlug);
|
||||
const currentSha = resolveActionRefSha(
|
||||
repoSlug,
|
||||
currentRef,
|
||||
tagMap,
|
||||
process.cwd(),
|
||||
);
|
||||
if (!currentSha) {
|
||||
throw new Error(`Unable to resolve ref '${currentRef}' for ${repoSlug}.`);
|
||||
}
|
||||
const currentRelease = findReleaseBySha(releases, tagMap, currentSha);
|
||||
const latestSha = latestRelease
|
||||
? (tagMap.get(latestRelease.tag_name) ?? null)
|
||||
: null;
|
||||
const isCurrent = latestSha !== null && latestSha === currentSha;
|
||||
|
||||
return {
|
||||
actionPath,
|
||||
repoSlug,
|
||||
currentRef,
|
||||
currentSha,
|
||||
currentRelease,
|
||||
latestRelease,
|
||||
latestSha,
|
||||
notPinned: latestSha === null,
|
||||
isCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
function printAnalysis(analysis: ActionAnalysis): number {
|
||||
const logFieldValue = (field: string, value: string) => {
|
||||
log(`${COLORS.cyan}${field}:${COLORS.reset} ${value}`, {
|
||||
indent: 3,
|
||||
});
|
||||
};
|
||||
logFieldValue("Action", analysis.actionPath);
|
||||
logFieldValue("Repository", analysis.repoSlug);
|
||||
logFieldValue("Current ref", analysis.currentRef);
|
||||
logFieldValue("Current sha", analysis.currentSha);
|
||||
logFieldValue("Current release", analysis.currentRelease?.tag_name ?? "none");
|
||||
logFieldValue(
|
||||
"Current release URL",
|
||||
analysis.currentRelease?.html_url ?? "none",
|
||||
);
|
||||
logFieldValue("Latest release", analysis.latestRelease?.tag_name ?? "none");
|
||||
logFieldValue(
|
||||
"Latest pin",
|
||||
analysis.latestSha
|
||||
? `${analysis.actionPath}@${analysis.latestSha}`
|
||||
: "none",
|
||||
);
|
||||
logFieldValue(
|
||||
"Latest release URL",
|
||||
analysis.latestRelease?.html_url ?? "none",
|
||||
);
|
||||
if (analysis.latestSha === null) {
|
||||
logError(`Status: no release found`);
|
||||
return 1;
|
||||
}
|
||||
if (analysis.isCurrent) {
|
||||
logSuccess(`Status: up to date`);
|
||||
} else {
|
||||
logWarn(`Status: update available`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const checkLatestActionPinCommand = defineCommand({
|
||||
description: "Check whether a GitHub Actions reference is up to date",
|
||||
options: defineOptions(
|
||||
z.object({
|
||||
scan: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Scan a directory for pinned GitHub Actions references"),
|
||||
}),
|
||||
{ s: "scan" },
|
||||
),
|
||||
args: z.array(z.string()).max(2),
|
||||
action: async (options: { scan: boolean }, args: string[]) => {
|
||||
if (options.scan) {
|
||||
if (args.length > 1) {
|
||||
fail("--scan accepts at most one directory argument.");
|
||||
}
|
||||
|
||||
const rootDir = path.resolve(process.cwd(), args[0] ?? ".github");
|
||||
const pins = findActionPins(rootDir);
|
||||
if (pins.length === 0) {
|
||||
logInfo(`No pinned GitHub Actions found under ${rootDir}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let updateCount = 0;
|
||||
for (const pin of pins) {
|
||||
const analysis = await analyzeAction(pin.actionPath, pin.currentRef);
|
||||
logInfo(`File: ${pin.filePath}:${pin.lineNumber}`, {
|
||||
color: COLORS.yellow,
|
||||
});
|
||||
printAnalysis(analysis);
|
||||
console.log("");
|
||||
if (analysis.latestSha && !analysis.isCurrent) {
|
||||
updateCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logSuccess(`Scanned ${pins.length} pinned action reference(s).`);
|
||||
logWarn(`Updates available: ${updateCount}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let actionPath = "";
|
||||
let currentRef = "";
|
||||
|
||||
if (args.length === 1) {
|
||||
const singleArg = args[0] ?? "";
|
||||
const atIndex = singleArg.lastIndexOf("@");
|
||||
if (atIndex <= 0 || atIndex === singleArg.length - 1) {
|
||||
fail(
|
||||
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
|
||||
);
|
||||
}
|
||||
|
||||
actionPath = singleArg.slice(0, atIndex);
|
||||
currentRef = singleArg.slice(atIndex + 1);
|
||||
} else if (args.length === 2) {
|
||||
actionPath = args[0] ?? "";
|
||||
currentRef = args[1] ?? "";
|
||||
if (actionPath.length === 0 || currentRef.length === 0) {
|
||||
fail(
|
||||
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
|
||||
);
|
||||
}
|
||||
|
||||
const analysis = await analyzeAction(actionPath, currentRef);
|
||||
printAnalysis(analysis);
|
||||
|
||||
if (analysis.latestSha && !analysis.isCurrent) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Check whether a GitHub Actions reference is up to date",
|
||||
commands: {
|
||||
run: checkLatestActionPinCommand,
|
||||
},
|
||||
defaultCommand: checkLatestActionPinCommand,
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runCli(cliConfig);
|
||||
}
|
||||
|
||||
await runMain(main, 1);
|
||||
19
dev_scripts/check_release_tag.mjs → scripts/ops/check_release_tag.mts
Normal file → Executable file
19
dev_scripts/check_release_tag.mjs → scripts/ops/check_release_tag.mts
Normal file → Executable file
|
|
@ -1,10 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
import {
|
||||
fail,
|
||||
logInfo,
|
||||
logSuccess,
|
||||
readJsonFile,
|
||||
ROOT_DIR,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
import { fail, logSuccess, readJsonFile, ROOT_DIR } from "./lib.mjs";
|
||||
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const tag: string | undefined = process.env.RELEASE_TAG;
|
||||
if (!tag) {
|
||||
fail("RELEASE_TAG is required for release preflight checks.");
|
||||
logInfo("RELEASE_TAG is not set; skipping release tag preflight checks.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag)) {
|
||||
|
|
@ -14,8 +20,7 @@ if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag)) {
|
|||
}
|
||||
|
||||
const packageJsonPath = `${ROOT_DIR}/package.json`;
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
|
||||
const packageJson = readJsonFile(packageJsonPath) as { version?: string };
|
||||
if (
|
||||
typeof packageJson.version !== "string" ||
|
||||
packageJson.version.length === 0
|
||||
197
scripts/ops/opslib.mts
Normal file
197
scripts/ops/opslib.mts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import process from "node:process";
|
||||
import { runCaptureOutput, tryRun } from "../devopslib.mts";
|
||||
|
||||
export type SemVer = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
export type ActionRepoData = {
|
||||
tagToSha: Map<string, string>;
|
||||
shaToBestVersion: Map<string, SemVer>;
|
||||
};
|
||||
|
||||
export const SHA_PIN_PATTERN = /^[0-9a-f]{40}$/i;
|
||||
const SEMVER_TAG_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
|
||||
const actionRepoCache = new Map<string, ActionRepoData>();
|
||||
|
||||
function runGit(args: string[], cwd: string): string {
|
||||
return runCaptureOutput("git", args, {
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
function tryGit(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} {
|
||||
const result = tryRun("git", args, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
stdout: String(result.stdout ?? "").trim(),
|
||||
stderr: String(result.stderr ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseActionRepoSlug(actionPath: string): string {
|
||||
const parts = actionPath.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw new Error(
|
||||
`Action path '${actionPath}' must include at least owner/repo.`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
|
||||
export function parseSemVer(tag: string): SemVer | null {
|
||||
const match = SEMVER_TAG_PATTERN.exec(tag.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]);
|
||||
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
export function compareSemVer(left: SemVer, right: SemVer): number {
|
||||
if (left.major !== right.major) {
|
||||
return left.major - right.major;
|
||||
}
|
||||
if (left.minor !== right.minor) {
|
||||
return left.minor - right.minor;
|
||||
}
|
||||
return left.patch - right.patch;
|
||||
}
|
||||
|
||||
export function formatSemVer(version: SemVer): string {
|
||||
return `v${version.major}.${version.minor}.${version.patch}`;
|
||||
}
|
||||
|
||||
export function loadActionRepoData(
|
||||
repoSlug: string,
|
||||
cwd = process.cwd(),
|
||||
): ActionRepoData {
|
||||
const cached = actionRepoCache.get(repoSlug);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const remoteUrl = `https://github.com/${repoSlug}.git`;
|
||||
const output = runGit(["ls-remote", "--tags", remoteUrl], cwd);
|
||||
const tagToSha = new Map<string, string>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [sha, ref] = line.split(/\s+/);
|
||||
const prefix = "refs/tags/";
|
||||
if (!sha || !ref || !ref.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawTag = ref.slice(prefix.length);
|
||||
const isPeeled = rawTag.endsWith("^{}");
|
||||
const tag = isPeeled ? rawTag.slice(0, -3) : rawTag;
|
||||
if (isPeeled || !tagToSha.has(tag)) {
|
||||
tagToSha.set(tag, sha.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const shaToBestVersion = new Map<string, SemVer>();
|
||||
for (const [tag, sha] of tagToSha.entries()) {
|
||||
const version = parseSemVer(tag);
|
||||
if (!version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = shaToBestVersion.get(sha);
|
||||
if (!existing || compareSemVer(version, existing) > 0) {
|
||||
shaToBestVersion.set(sha, version);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = {
|
||||
tagToSha,
|
||||
shaToBestVersion,
|
||||
};
|
||||
actionRepoCache.set(repoSlug, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export function resolveActionRefSha(
|
||||
repoSlug: string,
|
||||
ref: string,
|
||||
tagToSha: Map<string, string>,
|
||||
cwd = process.cwd(),
|
||||
): string | null {
|
||||
const normalizedRef = ref.toLowerCase();
|
||||
if (SHA_PIN_PATTERN.test(normalizedRef)) {
|
||||
return normalizedRef;
|
||||
}
|
||||
|
||||
if (tagToSha.has(ref)) {
|
||||
return tagToSha.get(ref) ?? null;
|
||||
}
|
||||
|
||||
const remoteUrl = `https://github.com/${repoSlug}.git`;
|
||||
const resolved = tryGit(["ls-remote", remoteUrl, ref], cwd);
|
||||
if (!resolved.ok || !resolved.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = resolved.stdout.split("\n")[0] ?? "";
|
||||
const [sha] = firstLine.split(/\s+/);
|
||||
return sha ? sha.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export async function isAdminActor(
|
||||
actor: string,
|
||||
repository: string,
|
||||
token = process.env.GITHUB_TOKEN ?? "",
|
||||
): Promise<boolean> {
|
||||
if (!actor || !repository || !token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(actor)}/permission`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "ts-apt-opslib",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { permission?: string };
|
||||
return payload.permission === "admin";
|
||||
}
|
||||
499
scripts/ops/pr_checks.mts
Executable file
499
scripts/ops/pr_checks.mts
Executable file
|
|
@ -0,0 +1,499 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { defineCommand, defineOptions } from "@robingenz/zli";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
compareSemVer,
|
||||
formatSemVer,
|
||||
isAdminActor,
|
||||
loadActionRepoData,
|
||||
parseActionRepoSlug,
|
||||
resolveActionRefSha,
|
||||
SHA_PIN_PATTERN,
|
||||
} from "./opslib.mts";
|
||||
import {
|
||||
createCliConfig,
|
||||
fail,
|
||||
logError,
|
||||
logInfo,
|
||||
logWarn,
|
||||
logSuccess,
|
||||
ROOT_DIR,
|
||||
runCli,
|
||||
runMain,
|
||||
runCaptureOutput,
|
||||
tryRun,
|
||||
} from "../devopslib.mts";
|
||||
|
||||
type PrCheckOptions = {
|
||||
baseRef: string;
|
||||
headRef: string;
|
||||
allowAdminBypass: boolean;
|
||||
};
|
||||
|
||||
type ActionUse = {
|
||||
actionPath: string;
|
||||
ref: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
type ActionRefChange = {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
actionPath: string;
|
||||
previousRef: string | null;
|
||||
currentRef: string;
|
||||
};
|
||||
|
||||
function runGitNameOnly(args: string[], cwd: string): string[] {
|
||||
const output = runCaptureOutput("git", args, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function tryGit(args: string[]): {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} {
|
||||
const result = tryRun("git", args, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
stdout: String(result.stdout ?? "").trim(),
|
||||
stderr: String(result.stderr ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function extractActionUses(content: string): ActionUse[] {
|
||||
const usesPattern = /^\s*uses:\s*["']?([^"'\s#]+)["']?/;
|
||||
const uses: ActionUse[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const match = usesPattern.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spec = match[1] ?? "";
|
||||
if (
|
||||
!spec.includes("@") ||
|
||||
spec.startsWith("./") ||
|
||||
spec.startsWith("docker://")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const atIndex = spec.lastIndexOf("@");
|
||||
if (atIndex <= 0 || atIndex === spec.length - 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionPath = spec.slice(0, atIndex);
|
||||
const ref = spec.slice(atIndex + 1);
|
||||
uses.push({
|
||||
actionPath,
|
||||
ref,
|
||||
lineNumber: index + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return uses;
|
||||
}
|
||||
|
||||
function groupUsesByActionPath(uses: ActionUse[]): Map<string, ActionUse[]> {
|
||||
const grouped = new Map<string, ActionUse[]>();
|
||||
for (const use of uses) {
|
||||
const list = grouped.get(use.actionPath) ?? [];
|
||||
list.push(use);
|
||||
grouped.set(use.actionPath, list);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function findActionRefChanges(
|
||||
filePath: string,
|
||||
previousContent: string,
|
||||
currentContent: string,
|
||||
): ActionRefChange[] {
|
||||
const previousByPath = groupUsesByActionPath(
|
||||
extractActionUses(previousContent),
|
||||
);
|
||||
const currentByPath = groupUsesByActionPath(
|
||||
extractActionUses(currentContent),
|
||||
);
|
||||
const actionPaths = new Set<string>([
|
||||
...previousByPath.keys(),
|
||||
...currentByPath.keys(),
|
||||
]);
|
||||
const changes: ActionRefChange[] = [];
|
||||
|
||||
for (const actionPath of actionPaths) {
|
||||
const previousUses = previousByPath.get(actionPath) ?? [];
|
||||
const currentUses = currentByPath.get(actionPath) ?? [];
|
||||
const pairedCount = Math.min(previousUses.length, currentUses.length);
|
||||
|
||||
for (let index = 0; index < pairedCount; index += 1) {
|
||||
const previousUse = previousUses[index];
|
||||
const currentUse = currentUses[index];
|
||||
if (!previousUse || !currentUse) {
|
||||
continue;
|
||||
}
|
||||
if (previousUse.ref === currentUse.ref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
filePath,
|
||||
lineNumber: currentUse.lineNumber,
|
||||
actionPath,
|
||||
previousRef: previousUse.ref,
|
||||
currentRef: currentUse.ref,
|
||||
});
|
||||
}
|
||||
|
||||
if (currentUses.length > previousUses.length) {
|
||||
for (
|
||||
let index = previousUses.length;
|
||||
index < currentUses.length;
|
||||
index += 1
|
||||
) {
|
||||
const currentUse = currentUses[index];
|
||||
if (!currentUse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
filePath,
|
||||
lineNumber: currentUse.lineNumber,
|
||||
actionPath,
|
||||
previousRef: null,
|
||||
currentRef: currentUse.ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function getChangedWorkflowYamlFiles(diffRange: string): string[] {
|
||||
const files = runGitNameOnly(
|
||||
[
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMR",
|
||||
diffRange,
|
||||
"--",
|
||||
".github/workflows",
|
||||
".github/actions",
|
||||
],
|
||||
ROOT_DIR,
|
||||
);
|
||||
|
||||
return files.filter((filePath) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ext === ".yml" || ext === ".yaml";
|
||||
});
|
||||
}
|
||||
|
||||
function getFileContentAtRef(ref: string, filePath: string): string | null {
|
||||
const result = tryGit(["show", `${ref}:${filePath}`]);
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function validateModifiedActionRefs(
|
||||
baseRef: string,
|
||||
headRef: string,
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const fetched = tryGit([
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--depth=200",
|
||||
"origin",
|
||||
baseRef,
|
||||
headRef,
|
||||
]);
|
||||
if (!fetched.ok) {
|
||||
errors.push(
|
||||
`Unable to fetch refs for action pin checks (${baseRef}, ${headRef}): ${fetched.stderr || "unknown error"}`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
const baseRemoteRef = `origin/${baseRef}`;
|
||||
const headRemoteRef = `origin/${headRef}`;
|
||||
const diffRange = `${baseRemoteRef}...${headRemoteRef}`;
|
||||
const changedFiles = getChangedWorkflowYamlFiles(diffRange);
|
||||
if (changedFiles.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const actionRefChanges: ActionRefChange[] = [];
|
||||
for (const filePath of changedFiles) {
|
||||
const previousContent = getFileContentAtRef(baseRemoteRef, filePath) ?? "";
|
||||
const currentContent = getFileContentAtRef(headRemoteRef, filePath);
|
||||
if (currentContent === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actionRefChanges.push(
|
||||
...findActionRefChanges(filePath, previousContent, currentContent),
|
||||
);
|
||||
}
|
||||
|
||||
if (actionRefChanges.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
logInfo(
|
||||
`Validating ${actionRefChanges.length} modified GitHub Action reference(s) in PR changes.`,
|
||||
);
|
||||
|
||||
for (const change of actionRefChanges) {
|
||||
if (!SHA_PIN_PATTERN.test(change.currentRef)) {
|
||||
errors.push(
|
||||
`${change.filePath}:${change.lineNumber} '${change.actionPath}@${change.currentRef}' must use a full 40-character commit SHA pin.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!change.previousRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const repoSlug = parseActionRepoSlug(change.actionPath);
|
||||
const { tagToSha, shaToBestVersion } = loadActionRepoData(
|
||||
repoSlug,
|
||||
ROOT_DIR,
|
||||
);
|
||||
const previousSha = resolveActionRefSha(
|
||||
repoSlug,
|
||||
change.previousRef,
|
||||
tagToSha,
|
||||
ROOT_DIR,
|
||||
);
|
||||
const currentSha = resolveActionRefSha(
|
||||
repoSlug,
|
||||
change.currentRef,
|
||||
tagToSha,
|
||||
ROOT_DIR,
|
||||
);
|
||||
|
||||
if (!previousSha || !currentSha) {
|
||||
warnings.push(
|
||||
`${change.filePath}:${change.lineNumber} could not resolve refs for '${change.actionPath}' to compare version progression.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousVersion = shaToBestVersion.get(previousSha);
|
||||
const currentVersion = shaToBestVersion.get(currentSha);
|
||||
if (!previousVersion || !currentVersion) {
|
||||
warnings.push(
|
||||
`${change.filePath}:${change.lineNumber} no semver release tags found for '${change.actionPath}' refs; skipping downgrade check.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (compareSemVer(currentVersion, previousVersion) < 0) {
|
||||
errors.push(
|
||||
`${change.filePath}:${change.lineNumber} '${change.actionPath}' was downgraded from ${formatSemVer(previousVersion)} to ${formatSemVer(currentVersion)}.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errors.push(
|
||||
`${change.filePath}:${change.lineNumber} failed to validate '${change.actionPath}': ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
logWarn(warning);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
const PROHIBITED_PATHS = [
|
||||
"docs/api",
|
||||
"docs/api-md",
|
||||
];
|
||||
|
||||
function getProhibitedPathsChanges(): string[] {
|
||||
const prohibitedPaths = PROHIBITED_PATHS;
|
||||
const staged = runGitNameOnly(
|
||||
["diff", "--name-only", "--cached", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
const unstaged = runGitNameOnly(
|
||||
["diff", "--name-only", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
const untracked = runGitNameOnly(
|
||||
["ls-files", "--others", "--exclude-standard", "--", ...prohibitedPaths],
|
||||
ROOT_DIR,
|
||||
);
|
||||
|
||||
return [...new Set([...staged, ...unstaged, ...untracked])].sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function getNormalizedRef(ref: string): string {
|
||||
return ref.trim();
|
||||
}
|
||||
|
||||
function resolveRefs(options: PrCheckOptions): {
|
||||
baseRef: string;
|
||||
headRef: string;
|
||||
} {
|
||||
const baseRef = getNormalizedRef(
|
||||
options.baseRef || process.env.GITHUB_BASE_REF || "",
|
||||
);
|
||||
const headRef = getNormalizedRef(
|
||||
options.headRef || process.env.GITHUB_HEAD_REF || "",
|
||||
);
|
||||
|
||||
if (
|
||||
(baseRef.length > 0 && headRef.length === 0) ||
|
||||
(headRef.length > 0 && baseRef.length === 0)
|
||||
) {
|
||||
fail(
|
||||
"Both base and head refs must be provided together (flags or environment).",
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
return { baseRef, headRef };
|
||||
}
|
||||
|
||||
async function runChecks(options: PrCheckOptions): Promise<string[]> {
|
||||
const { baseRef, headRef } = resolveRefs(options);
|
||||
const errors: string[] = [];
|
||||
|
||||
const changedPaths = getProhibitedPathsChanges();
|
||||
|
||||
if (baseRef.length > 0 && headRef.length > 0) {
|
||||
if (baseRef === "main" && headRef !== "staging") {
|
||||
errors.push(
|
||||
`Merge to 'main' is only allowed from 'staging'. Current source: '${headRef}'.`,
|
||||
);
|
||||
errors.push("To fix this, rebase your feature branch onto 'staging'.");
|
||||
}
|
||||
|
||||
errors.push(...validateModifiedActionRefs(baseRef, headRef));
|
||||
} else {
|
||||
logInfo("Base/head refs were not provided; skipping branch policy check.");
|
||||
}
|
||||
|
||||
if (changedPaths.length > 0) {
|
||||
errors.push(
|
||||
`Detected prohibited path changes in:\n${changedPaths.join(", ")}\n` +
|
||||
`Please remove changes to these paths before proceeding.\n` +
|
||||
`These paths are restricted and should not be modified in pull requests.\n` +
|
||||
`Prohibited paths include:\n -${PROHIBITED_PATHS.join("\n -")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function shouldAllowAdminBypass(
|
||||
options: PrCheckOptions,
|
||||
): Promise<boolean> {
|
||||
if (!options.allowAdminBypass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isAdminActor(
|
||||
process.env.GITHUB_ACTOR ?? "",
|
||||
process.env.GITHUB_REPOSITORY ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
const prChecksCommand = defineCommand({
|
||||
description: "Run pull request checks",
|
||||
options: defineOptions(
|
||||
z.object({
|
||||
baseRef: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("Pull request base branch ref (for example: staging)"),
|
||||
headRef: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe(
|
||||
"Pull request head branch ref (for example: feature/my-feature)",
|
||||
),
|
||||
allowAdminBypass: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Allow bypass when the GitHub actor has admin permission"),
|
||||
}),
|
||||
{
|
||||
b: "baseRef",
|
||||
h: "headRef",
|
||||
a: "allowAdminBypass",
|
||||
},
|
||||
),
|
||||
action: async (options: PrCheckOptions) => {
|
||||
const errors = await runChecks(options);
|
||||
const allowAdminBypass = await shouldAllowAdminBypass(options);
|
||||
|
||||
if (errors.length == 0) {
|
||||
logSuccess("Pull request checks passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
logError(error);
|
||||
}
|
||||
if (allowAdminBypass) {
|
||||
logWarn(
|
||||
"Admin bypass is allowed for this pull request actor. Treating as warning only.",
|
||||
);
|
||||
} else {
|
||||
fail("Pull request checks failed.");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cliConfig = createCliConfig({
|
||||
importMetaUrl: import.meta.url,
|
||||
description: "Run pull request checks",
|
||||
commands: {
|
||||
run: prChecksCommand,
|
||||
},
|
||||
defaultCommand: prChecksCommand,
|
||||
});
|
||||
|
||||
await runMain(async () => {
|
||||
await runCli(cliConfig);
|
||||
});
|
||||
3
scripts/tsconfig.json
Normal file
3
scripts/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../tsconfig.scripts.json"
|
||||
}
|
||||
412
src/action.ts
412
src/action.ts
|
|
@ -1,28 +1,14 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPackageManager,
|
||||
type CommandRunner,
|
||||
DefaultCommandRunner,
|
||||
type PackageManager,
|
||||
} from "../node_modules/ts-apt/dist/index.js";
|
||||
import { type PackageName } from "../node_modules/ts-apt/dist/types.js";
|
||||
import { isAptListsFresh } from "./io.js";
|
||||
import { readManifestAsCsv, writeManifest } from "./manifest.js";
|
||||
import * as tar from "tar";
|
||||
import { createPackageManager, type CommandRunner } from "ts-apt";
|
||||
import { Cache, CacheKey } from "./cache.ts";
|
||||
import { Manifest } from "./manifest.js";
|
||||
import { ActionPackageNames } from "./packages.js";
|
||||
import winston from "winston";
|
||||
|
||||
type TarModule = typeof import("tar");
|
||||
|
||||
type EmptyPackageBehavior = "error" | "warn" | "ignore";
|
||||
|
||||
const FORCE_UPDATE_INCREMENT = "4";
|
||||
const CACHE_DIRNAME = "cache-apt-pkgs";
|
||||
const CACHE_PREFIX = "cache-apt-pkgs_";
|
||||
const FORCE_UPDATE_INCREMENT = "0";
|
||||
|
||||
/** Inputs accepted by the GitHub Action runtime. */
|
||||
export interface ActionInputs {
|
||||
readonly packages: string;
|
||||
readonly version: string;
|
||||
|
|
@ -31,118 +17,40 @@ export interface ActionInputs {
|
|||
readonly debug: boolean;
|
||||
}
|
||||
|
||||
/** Outputs emitted by the GitHub Action runtime. */
|
||||
export interface ActionOutputs {
|
||||
readonly cacheHit: boolean;
|
||||
readonly packageVersionList: string;
|
||||
readonly allPackageVersionList: string;
|
||||
}
|
||||
|
||||
export class ActionPackageName implements PackageName {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly version?: string,
|
||||
readonly distro?: string,
|
||||
) {}
|
||||
|
||||
serialize(): string {
|
||||
return this.version ? `${this.name}=${this.version}` : this.name;
|
||||
}
|
||||
}
|
||||
|
||||
function toPackageName(packageSpecifier: string): ActionPackageName {
|
||||
const [name, version] = packageSpecifier.split("=");
|
||||
if (!name) {
|
||||
throw new Error("Package name cannot be empty.");
|
||||
}
|
||||
|
||||
return new ActionPackageName(name, version);
|
||||
}
|
||||
|
||||
export function parseBoolean(value: string, fieldName: string): boolean {
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${fieldName} value '${value}' must be either true or false.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeInputPackages(inputPackages: string): string[] {
|
||||
return inputPackages
|
||||
.replace(/[,\\]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** Orchestrates package normalization, cache restore/save, install, and outputs. */
|
||||
export class ActionRunner {
|
||||
private readonly cache: Cache;
|
||||
private readonly commandRunner: CommandRunner;
|
||||
private readonly tar: TarModule;
|
||||
private readonly logger: winston.Logger;
|
||||
|
||||
constructor(
|
||||
cache: Cache,
|
||||
commandRunner: CommandRunner,
|
||||
tarModule: TarModule,
|
||||
logger: winston.Logger,
|
||||
) {
|
||||
this.cache = cache;
|
||||
this.commandRunner = commandRunner;
|
||||
this.tar = tarModule;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
parseBoolean(value: string, fieldName: string): boolean {
|
||||
return parseBoolean(value, fieldName);
|
||||
}
|
||||
|
||||
normalizeInputPackages(inputPackages: string): string[] {
|
||||
return normalizeInputPackages(inputPackages);
|
||||
}
|
||||
|
||||
async resolvePackageVersion(
|
||||
packageManager: PackageManager,
|
||||
packageName: string,
|
||||
): Promise<string> {
|
||||
const packageInfo = await packageManager.getPackageInfo([
|
||||
toPackageName(packageName),
|
||||
]);
|
||||
const version = packageInfo[0]?.version;
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`Unable to resolve package version for '${packageName}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
async normalizePackagesWithVersions(
|
||||
packageManager: PackageManager,
|
||||
inputPackages: string,
|
||||
): Promise<string[]> {
|
||||
const raw = this.normalizeInputPackages(inputPackages);
|
||||
const packages = await Promise.all(
|
||||
raw.map(async (pkg) => {
|
||||
if (pkg.includes("=")) {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
return `${pkg}=${await this.resolvePackageVersion(packageManager, pkg)}`;
|
||||
}),
|
||||
);
|
||||
|
||||
return packages.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies configured behavior when package input resolves to an empty set.
|
||||
*
|
||||
* @param behavior Empty-package handling strategy.
|
||||
* @param packages Normalized package list.
|
||||
* @returns Nothing.
|
||||
* @throws Error when behavior is error and packages is empty.
|
||||
*/
|
||||
validateEmptyPackages(
|
||||
behavior: EmptyPackageBehavior,
|
||||
packages: string[],
|
||||
packages: ActionPackageNames,
|
||||
): void {
|
||||
if (packages.length > 0) {
|
||||
return;
|
||||
|
|
@ -160,259 +68,71 @@ export class ActionRunner {
|
|||
throw new Error("Packages argument is empty.");
|
||||
}
|
||||
|
||||
getCacheRoot(): string {
|
||||
return path.join(os.homedir(), CACHE_DIRNAME);
|
||||
}
|
||||
|
||||
async getCacheKey(
|
||||
normalizedPackages: string[],
|
||||
version: string,
|
||||
): Promise<string> {
|
||||
const architecture = (await this.commandRunner.run("arch")).stdout.trim();
|
||||
let value = `${normalizedPackages.join(" ")} @ ${version} ${FORCE_UPDATE_INCREMENT}`;
|
||||
|
||||
if (architecture !== "x86_64") {
|
||||
value = `${value} ${architecture}`;
|
||||
}
|
||||
|
||||
const hash = crypto.createHash("md5").update(value).digest("hex");
|
||||
return `${CACHE_PREFIX}${hash}`;
|
||||
}
|
||||
|
||||
findInstallScript(
|
||||
packageName: string,
|
||||
extension: "preinst" | "postinst",
|
||||
root: string,
|
||||
): string | undefined {
|
||||
const scriptsDir = path.join(root, "var", "lib", "dpkg", "info");
|
||||
if (!fs.existsSync(scriptsDir)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${packageName}(:.*)?\\.${extension}$`);
|
||||
const matches = fs
|
||||
.readdirSync(scriptsDir)
|
||||
.filter((entry) => pattern.test(entry))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const candidate = matches[0];
|
||||
if (!candidate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path.join(scriptsDir, candidate);
|
||||
}
|
||||
|
||||
tarRelativePath(filePath: string): string {
|
||||
return filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
||||
}
|
||||
|
||||
async buildFileListForPackage(
|
||||
packageManager: PackageManager,
|
||||
packageName: string,
|
||||
): Promise<string[]> {
|
||||
const files = (
|
||||
await packageManager.listInstalledFiles(toPackageName(packageName))
|
||||
)
|
||||
.filter((filePath) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(filePath);
|
||||
return stat.isFile() || stat.isSymbolicLink();
|
||||
})
|
||||
.map(this.tarRelativePath);
|
||||
|
||||
const preinst = this.findInstallScript(packageName, "preinst", "/");
|
||||
const postinst = this.findInstallScript(packageName, "postinst", "/");
|
||||
|
||||
if (preinst) {
|
||||
files.push(this.tarRelativePath(preinst));
|
||||
}
|
||||
if (postinst) {
|
||||
files.push(this.tarRelativePath(postinst));
|
||||
}
|
||||
|
||||
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async updateAptLists(packageManager: PackageManager): Promise<void> {
|
||||
if (isAptListsFresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await packageManager.update();
|
||||
}
|
||||
|
||||
packageSpecifierToName(packageSpecifier: string): string {
|
||||
return packageSpecifier.split("=")[0] ?? packageSpecifier;
|
||||
}
|
||||
|
||||
async installAndCachePackages(
|
||||
cacheDir: string,
|
||||
packages: string[],
|
||||
packageManager: PackageManager,
|
||||
): Promise<void> {
|
||||
await this.updateAptLists(packageManager);
|
||||
writeManifest(path.join(cacheDir, "manifest_main.log"), packages);
|
||||
|
||||
const installedPackages = await packageManager.install(
|
||||
packages.map(toPackageName),
|
||||
);
|
||||
|
||||
const manifestAll: string[] = [];
|
||||
for (const pkg of installedPackages) {
|
||||
const packageName = pkg.name;
|
||||
const packageVersion = pkg.version;
|
||||
if (!packageName || !packageVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const archivePath = path.join(
|
||||
cacheDir,
|
||||
`${packageName}=${packageVersion}.tar`,
|
||||
);
|
||||
if (!fs.existsSync(archivePath)) {
|
||||
const filesToArchive = await this.buildFileListForPackage(
|
||||
packageManager,
|
||||
packageName,
|
||||
);
|
||||
await this.tar.create(
|
||||
{
|
||||
cwd: "/",
|
||||
file: archivePath,
|
||||
portable: false,
|
||||
preservePaths: false,
|
||||
follow: false,
|
||||
noDirRecurse: false,
|
||||
},
|
||||
filesToArchive,
|
||||
);
|
||||
}
|
||||
|
||||
manifestAll.push(`${packageName}=${packageVersion}`);
|
||||
}
|
||||
|
||||
writeManifest(path.join(cacheDir, "manifest_all.log"), manifestAll);
|
||||
}
|
||||
|
||||
async restorePackages(
|
||||
cacheDir: string,
|
||||
executeInstallScripts: boolean,
|
||||
): Promise<void> {
|
||||
const archives = fs
|
||||
.readdirSync(cacheDir)
|
||||
.filter((entry) => entry.endsWith(".tar"))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
for (const archive of archives) {
|
||||
const archivePath = path.join(cacheDir, archive);
|
||||
await this.tar.extract({
|
||||
cwd: "/",
|
||||
file: archivePath,
|
||||
preservePaths: true,
|
||||
});
|
||||
|
||||
if (!executeInstallScripts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageName = archive.split("=")[0] ?? "";
|
||||
if (!packageName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preinst = this.findInstallScript(packageName, "preinst", "/");
|
||||
if (preinst) {
|
||||
this.logger.info(`Running pre-install script for ${packageName}`);
|
||||
await this.commandRunner.run("sudo", ["sh", "-x", preinst, "install"]);
|
||||
}
|
||||
|
||||
const postinst = this.findInstallScript(packageName, "postinst", "/");
|
||||
if (postinst) {
|
||||
this.logger.info(`Running post-install script for ${packageName}`);
|
||||
await this.commandRunner.run("sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
postinst,
|
||||
"configure",
|
||||
]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Converts manifest entries into the action output CSV format.
|
||||
*
|
||||
* @param manifest Parsed manifest.
|
||||
* @returns Comma-delimited name=version list.
|
||||
*/
|
||||
private toCsv(manifest: Manifest): string {
|
||||
return manifest.entries
|
||||
.map((entry) => entry.packageName.serialize())
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the end-to-end action flow and returns action outputs.
|
||||
*
|
||||
* @param inputs Validated action inputs.
|
||||
* @returns Action outputs consumed by the workflow runtime.
|
||||
*/
|
||||
async runAction(inputs: ActionInputs): Promise<ActionOutputs> {
|
||||
if (/\s/.test(inputs.version)) {
|
||||
throw new Error(
|
||||
`Version value '${inputs.version}' cannot contain spaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
const packageInfoManager = await createPackageManager(false);
|
||||
const normalizedPackages = await this.normalizePackagesWithVersions(
|
||||
packageInfoManager,
|
||||
inputs.packages,
|
||||
);
|
||||
this.validateEmptyPackages(
|
||||
inputs.emptyPackagesBehavior,
|
||||
normalizedPackages,
|
||||
const packageNames = ActionPackageNames.fromInput(inputs.packages);
|
||||
const cacheKey = new CacheKey(
|
||||
inputs.version,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
process.arch,
|
||||
packageNames,
|
||||
);
|
||||
|
||||
const cacheDir = this.getCacheRoot();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
if (normalizedPackages.length === 0) {
|
||||
writeManifest(path.join(cacheDir, "manifest_main.log"), []);
|
||||
writeManifest(path.join(cacheDir, "manifest_all.log"), []);
|
||||
return {
|
||||
cacheHit: false,
|
||||
packageVersionList: "",
|
||||
allPackageVersionList: "",
|
||||
};
|
||||
}
|
||||
|
||||
const key = await this.getCacheKey(normalizedPackages, inputs.version);
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, "cache_key.md5"),
|
||||
key.replace(CACHE_PREFIX, ""),
|
||||
"utf8",
|
||||
let manifest = await this.cache.loadAndRestore(
|
||||
cacheKey,
|
||||
inputs.executeInstallScripts,
|
||||
);
|
||||
const cacheHit = manifest !== undefined;
|
||||
|
||||
const restoredKey = await cache.restoreCache([cacheDir], key);
|
||||
const cacheHit = restoredKey === key;
|
||||
|
||||
if (cacheHit) {
|
||||
await this.restorePackages(cacheDir, inputs.executeInstallScripts);
|
||||
} else {
|
||||
const installManager = await createPackageManager(true);
|
||||
const installTargets = normalizedPackages.map((packageSpecifier) =>
|
||||
this.packageSpecifierToName(packageSpecifier),
|
||||
if (!cacheHit) {
|
||||
const installManager = await createPackageManager(
|
||||
true,
|
||||
this.logger,
|
||||
this.logger,
|
||||
);
|
||||
await this.installAndCachePackages(
|
||||
cacheDir,
|
||||
installTargets,
|
||||
installManager,
|
||||
);
|
||||
await cache.saveCache([cacheDir], key);
|
||||
const packageInfos = await installManager.install(packageNames.toArray());
|
||||
await this.cache.archiveAndSave(cacheKey, packageInfos);
|
||||
}
|
||||
|
||||
return {
|
||||
cacheHit,
|
||||
packageVersionList: readManifestAsCsv(
|
||||
path.join(cacheDir, "manifest_main.log"),
|
||||
),
|
||||
allPackageVersionList: readManifestAsCsv(
|
||||
path.join(cacheDir, "manifest_all.log"),
|
||||
),
|
||||
packageVersionList: this.toCsv(manifest!),
|
||||
allPackageVersionList: this.toCsv(manifest!),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entrypoint used by src/index.ts and tests.
|
||||
*
|
||||
* @param inputs Validated action inputs.
|
||||
* @param commandRunner Command runner used by package operations.
|
||||
* @param logger Logger instance used for command and restore diagnostics.
|
||||
* @returns Action outputs consumed by the workflow runtime.
|
||||
*/
|
||||
export async function runAction(
|
||||
inputs: ActionInputs,
|
||||
commandRunner: CommandRunner,
|
||||
cache: Cache,
|
||||
logger: winston.Logger,
|
||||
): Promise<ActionOutputs> {
|
||||
const commandRunner = new DefaultCommandRunner(logger, logger);
|
||||
const actionRunner = new ActionRunner(commandRunner, tar, logger);
|
||||
const actionRunner = new ActionRunner(cache, commandRunner, logger);
|
||||
return await actionRunner.runAction(inputs);
|
||||
}
|
||||
|
|
|
|||
314
src/cache.ts
Normal file
314
src/cache.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import * as ghcache from "@actions/cache";
|
||||
import * as winston from "winston";
|
||||
import * as tar from "tar";
|
||||
import * as crypto from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ActionPackageNames, findInstallScript } from "./packages.ts";
|
||||
import { Manifest, ManifestEntry } from "./manifest.ts";
|
||||
import { CommandRunner, PackageInfo, PackageName } from "ts-apt/types.js";
|
||||
import { deserializePackageName } from "ts-apt/package.ts";
|
||||
|
||||
export const CACHE_DEFAULT_DIRNAME = "cache-apt-pkgs";
|
||||
export const CACHE_KEY_FILENAME = "cache_key.md5";
|
||||
|
||||
export const MANIFEST_MAIN_FILENAME = "manifest_main.json";
|
||||
export const MANIFEST_ALL_FILENAME = "manifest_all.json";
|
||||
|
||||
/**
|
||||
* Structured representation of cache key components for the GitHub Actions cache service.
|
||||
*/
|
||||
export class CacheKey {
|
||||
/** Version set by user to create a miss, re-install and cache from scratch. */
|
||||
readonly version: string;
|
||||
/** Global force update increment for cases of cache bugs introduced by action development. */
|
||||
readonly forceUpdateIncrement: string;
|
||||
/** System architecture for which the cache is valid. Prevent cross architecture cache hits. */
|
||||
readonly arch: string;
|
||||
/** Normalized package names included in the cache so future unordered list comparisons are consistent. */
|
||||
readonly packageNames: ActionPackageNames;
|
||||
/** MD5 hash of the serialized cache key. Not considered sensitive in the GitHub Cache action. */
|
||||
readonly hash: string;
|
||||
|
||||
constructor(
|
||||
version: string,
|
||||
forceUpdateIncrement: string,
|
||||
arch: string,
|
||||
packageNames: ActionPackageNames,
|
||||
) {
|
||||
this.version = version;
|
||||
this.forceUpdateIncrement = forceUpdateIncrement;
|
||||
this.arch = arch;
|
||||
this.packageNames = packageNames;
|
||||
this.hash = crypto.createHash("md5").update(this.toJSON()).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a serialized cache key into a strongly typed CacheKey.
|
||||
*
|
||||
* @param serialized Serialized cache key string.
|
||||
* @returns Parsed cache key object.
|
||||
* @throws Error when serialized value does not contain all expected fields.
|
||||
*/
|
||||
static fromJSON(json: string): CacheKey {
|
||||
const obj = JSON.parse(json);
|
||||
return new CacheKey(
|
||||
obj.version!,
|
||||
obj.forceUpdateIncrement!,
|
||||
obj.arch!,
|
||||
ActionPackageNames.fromJSON(obj.packageNames!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes cache key fields to a stable, human-readable format.
|
||||
*
|
||||
* @returns Serialized cache key components.
|
||||
*/
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this, null, 2);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.hash} (key input: ${JSON.stringify(this)})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes action cache path and cache keys for package sets.
|
||||
*/
|
||||
export class Cache {
|
||||
/** Absolute path to the local cache directory. */
|
||||
readonly path: string;
|
||||
|
||||
private readonly logger: winston.Logger;
|
||||
private readonly commandRunner: CommandRunner;
|
||||
|
||||
constructor(
|
||||
commandRunner: CommandRunner,
|
||||
logger: winston.Logger,
|
||||
cacheDir: string = CACHE_DEFAULT_DIRNAME,
|
||||
) {
|
||||
this.path = path.join(os.homedir(), cacheDir);
|
||||
this.commandRunner = commandRunner;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private async runInstallScripts(archiveFilename: string): Promise<void> {
|
||||
const serializePackageName = archiveFilename.replace(/\.tar$/, "");
|
||||
const packageName = deserializePackageName(serializePackageName);
|
||||
|
||||
const runScript = async (
|
||||
packageName: PackageName,
|
||||
scriptType: "preinst" | "postinst",
|
||||
) => {
|
||||
const scriptPath = await findInstallScript(packageName, scriptType, "/");
|
||||
if (!scriptPath) {
|
||||
this.logger.info(
|
||||
`No ${scriptType} script found for package ${packageName.serialize()}, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const scriptName = path.basename(scriptPath);
|
||||
this.logger.info(
|
||||
`Running ${scriptType} script '${scriptName}' for package ${packageName.serialize()}...`,
|
||||
);
|
||||
await this.commandRunner.run("sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
scriptPath,
|
||||
scriptType === "preinst" ? "install" : "configure",
|
||||
]);
|
||||
this.logger.info(`Ran ${scriptType} script for ${packageName.name}`);
|
||||
};
|
||||
|
||||
await runScript(packageName, "preinst");
|
||||
await runScript(packageName, "postinst");
|
||||
}
|
||||
|
||||
async loadAndRestore(
|
||||
key: CacheKey,
|
||||
executeInstallScripts: boolean,
|
||||
): Promise<Manifest | undefined> {
|
||||
const cacheHit = await ghcache.restoreCache([this.path], key.hash);
|
||||
|
||||
if (!cacheHit) {
|
||||
this.logger.info(
|
||||
`Cache miss for key ${key.hash}, skipping cache restore.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const logFileError = (prefixMessage: string) => {
|
||||
this.logger.error(
|
||||
`${prefixMessage}, skipping cache restore.\n` +
|
||||
`This may indicate a cache corruption or an unexpected cache hit for a different package set.\n` +
|
||||
fs
|
||||
.readdir(this.path)
|
||||
.then(
|
||||
(entries) => `Cache directory contents:\n${entries.join("\n")}`,
|
||||
)
|
||||
.catch(
|
||||
(reason: any) =>
|
||||
`Unable to read cache directory contents: ${reason}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.logger.info(`Cache hit for key ${key.hash}, restoring...`);
|
||||
const manifestPath = path.join(this.path, MANIFEST_MAIN_FILENAME);
|
||||
if (
|
||||
!fs
|
||||
.access(manifestPath, fs.constants.R_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
logFileError(`Manifest file not found at ${manifestPath}`);
|
||||
return undefined;
|
||||
}
|
||||
const archives = await fs
|
||||
.readdir(this.path)
|
||||
.then((entries: string[]) =>
|
||||
entries.filter((entry) => entry.endsWith(".tar")),
|
||||
)
|
||||
.then((entries: string[]) => entries.sort());
|
||||
|
||||
if (archives.length === 0) {
|
||||
logFileError(`No archive files found in cache directory ${this.path}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const archiveFilename of archives) {
|
||||
const archivePath = path.join(this.path, archiveFilename);
|
||||
await tar.extract({
|
||||
cwd: "/",
|
||||
file: archivePath,
|
||||
preservePaths: true,
|
||||
});
|
||||
|
||||
if (executeInstallScripts) {
|
||||
await this.runInstallScripts(archiveFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createArchive(manifestEntry: ManifestEntry): Promise<void> {
|
||||
const archivePath = path.join(
|
||||
this.path,
|
||||
manifestEntry.packageName.serialize() + ".tar",
|
||||
);
|
||||
if (
|
||||
await fs
|
||||
.access(archivePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Archive already exists for package ${manifestEntry.packageName.serialize()} at ${archivePath}, skipping creation.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.info(
|
||||
`Archiving package ${manifestEntry.packageName.serialize()} with ${manifestEntry.filepaths.length} files...`,
|
||||
);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: "/",
|
||||
file: archivePath,
|
||||
portable: false,
|
||||
preservePaths: false,
|
||||
follow: false,
|
||||
noDirRecurse: false,
|
||||
},
|
||||
manifestEntry.filepaths,
|
||||
);
|
||||
this.logger.info(
|
||||
`Archive created for package ${manifestEntry.packageName.serialize()} at ${archivePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async writeManifests(manifest: Manifest): Promise<void> {
|
||||
const allEntries = manifest.entries;
|
||||
const packages = manifest.cacheKey.packageNames;
|
||||
const entriesByName = new Map(
|
||||
allEntries.map((entry) => [entry.packageName, entry]),
|
||||
);
|
||||
const mainEntries = packages.toArray().map((pkg) => {
|
||||
const installed = entriesByName.get(pkg);
|
||||
return new ManifestEntry(pkg, installed?.filepaths ?? []);
|
||||
});
|
||||
|
||||
const write = async (manifest: Manifest, filename: string) => {
|
||||
this.logger.info(
|
||||
`Writing manifest ${filename} with ${manifest.entries.length} entries to ${this.path}`,
|
||||
);
|
||||
await fs.mkdir(this.path, { recursive: true });
|
||||
const filePath = path.join(this.path, filename);
|
||||
await manifest.writeToFile(filePath);
|
||||
this.logger.info(`Wrote manifest`);
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await write(
|
||||
new Manifest(now, mainEntries, manifest.cacheKey),
|
||||
MANIFEST_MAIN_FILENAME,
|
||||
);
|
||||
await write(
|
||||
new Manifest(now, allEntries, manifest.cacheKey),
|
||||
MANIFEST_ALL_FILENAME,
|
||||
);
|
||||
}
|
||||
|
||||
async archiveAndSave(
|
||||
key: CacheKey,
|
||||
packageInfos: PackageInfo[],
|
||||
): Promise<number> {
|
||||
if (!ghcache.isFeatureAvailable()) {
|
||||
throw new Error(
|
||||
"GitHub Actions cache service is not available in this environment",
|
||||
);
|
||||
}
|
||||
await fs.mkdir(this.path, { recursive: true });
|
||||
|
||||
this.logger.info(
|
||||
`Writing cache key to ${path.join(this.path, CACHE_KEY_FILENAME)} with ${key}...`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(this.path, CACHE_KEY_FILENAME),
|
||||
key.hash,
|
||||
"utf8",
|
||||
);
|
||||
this.logger.info(`Wrote cache key`);
|
||||
|
||||
const manifest = Manifest.from(new Date(), key, packageInfos);
|
||||
|
||||
await this.writeManifests(manifest);
|
||||
for (const entry of manifest.entries) {
|
||||
await this.createArchive(entry);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Saving cache with key ${key} for ${manifest.entries.length} entries...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const cacheId = await ghcache.saveCache(
|
||||
manifest.entries.flatMap((entry) => entry.filepaths),
|
||||
manifest.cacheKey.hash,
|
||||
);
|
||||
if (cacheId === undefined) {
|
||||
throw new Error("Cache save failed: no cache action ID returned.");
|
||||
}
|
||||
this.logger.info(`Saved cache with cache action ID: ${cacheId}`);
|
||||
return cacheId;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? `Failed to save cache: ${error.message}`
|
||||
: `Failed to save cache: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/index.ts
58
src/index.ts
|
|
@ -1,7 +1,16 @@
|
|||
import * as core from "@actions/core";
|
||||
import { parseBoolean, runAction, type ActionInputs } from "./action.js";
|
||||
import winston from "winston";
|
||||
import { runAction, type ActionInputs } from "./action.js";
|
||||
import { DefaultCommandRunner } from "ts-apt";
|
||||
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.ts";
|
||||
import { Instruments } from "./instrumentation.js";
|
||||
|
||||
/**
|
||||
* Parses empty package behavior input from workflow configuration.
|
||||
*
|
||||
* @param value Raw behavior input.
|
||||
* @returns Parsed behavior enum value.
|
||||
* @throws Error when value is outside the supported set.
|
||||
*/
|
||||
function parseEmptyPackagesBehavior(
|
||||
value: string,
|
||||
): "error" | "warn" | "ignore" {
|
||||
|
|
@ -14,36 +23,45 @@ function parseEmptyPackagesBehavior(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates typed action inputs from GitHub Actions runtime.
|
||||
*
|
||||
* @returns Parsed and validated action inputs.
|
||||
*/
|
||||
function getInputs(): ActionInputs {
|
||||
const executeInstallScriptsRaw = core.getInput("execute_install_scripts");
|
||||
const debugRaw = core.getInput("debug");
|
||||
const emptyPackagesBehaviorRaw =
|
||||
core.getInput("empty_packages_behavior") || "error";
|
||||
|
||||
return {
|
||||
packages: core.getInput("packages", { required: true }),
|
||||
version: core.getInput("version"),
|
||||
executeInstallScripts: parseBoolean(
|
||||
executeInstallScriptsRaw,
|
||||
"execute_install_scripts",
|
||||
),
|
||||
executeInstallScripts: core.getBooleanInput("execute_install_scripts"),
|
||||
emptyPackagesBehavior: parseEmptyPackagesBehavior(emptyPackagesBehaviorRaw),
|
||||
debug: parseBoolean(debugRaw, "debug"),
|
||||
debug: core.getBooleanInput("debug"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main action entrypoint. Sets outputs on success and fails the action on error.
|
||||
*
|
||||
* @returns Nothing.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const cacheDir = CACHE_DEFAULT_DIRNAME;
|
||||
const inputs = getInputs();
|
||||
const instruments = new Instruments(cacheDir, inputs.debug || core.isDebug());
|
||||
try {
|
||||
const inputs = getInputs();
|
||||
const logger = winston.createLogger({
|
||||
level: inputs.debug ? "debug" : "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ level, message }) => `${level}: ${message}`),
|
||||
),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
const outputs = await runAction(inputs, logger);
|
||||
const commandRunner = new DefaultCommandRunner(
|
||||
instruments.execLogger,
|
||||
instruments.execLogger,
|
||||
);
|
||||
|
||||
const outputs = await runAction(
|
||||
inputs,
|
||||
commandRunner,
|
||||
new Cache(commandRunner, instruments.appLogger, cacheDir),
|
||||
instruments.appLogger,
|
||||
);
|
||||
|
||||
core.setOutput("cache-hit", String(outputs.cacheHit));
|
||||
core.setOutput("package-version-list", outputs.packageVersionList);
|
||||
|
|
@ -51,6 +69,8 @@ async function main(): Promise<void> {
|
|||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(message);
|
||||
} finally {
|
||||
instruments.artifacts.upload();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
119
src/instrumentation.ts
Normal file
119
src/instrumentation.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import * as core from "@actions/core";
|
||||
import { DefaultArtifactClient } from "@actions/artifact";
|
||||
import winston from "winston";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
CACHE_KEY_FILENAME,
|
||||
MANIFEST_ALL_FILENAME,
|
||||
MANIFEST_MAIN_FILENAME,
|
||||
} from "./cache.ts";
|
||||
|
||||
const APP_LOG_FILENAME = "capa_app.log";
|
||||
const EXEC_LOG_FILENAME = "capa_exec.log";
|
||||
|
||||
function createStream(
|
||||
filepath: string,
|
||||
): winston.transports.StreamTransportInstance {
|
||||
return new winston.transports.Stream({
|
||||
stream: fs.createWriteStream(filepath, {
|
||||
flags: "a",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export class Artifacts {
|
||||
constructor(
|
||||
readonly dir: string,
|
||||
readonly artifactFilenames: string[],
|
||||
readonly debug: boolean = false,
|
||||
readonly runId: string = process.env.GITHUB_RUN_ID ??
|
||||
`ghrunid-notfound-${crypto.randomUUID()}`,
|
||||
) {}
|
||||
|
||||
upload(): string {
|
||||
const client = new DefaultArtifactClient();
|
||||
client.uploadArtifact(
|
||||
"capa_artifacts-" + this.runId,
|
||||
this.artifactFilenames
|
||||
.map((filename) => path.join(this.dir, filename))
|
||||
.filter((filepath) => fs.existsSync(filepath)),
|
||||
this.dir,
|
||||
{
|
||||
retentionDays: 7,
|
||||
},
|
||||
);
|
||||
return this.runId;
|
||||
}
|
||||
}
|
||||
|
||||
function createExecLogger(debug: boolean, filepath: string): winston.Logger {
|
||||
const logger = winston.createLogger({
|
||||
level: debug ? "debug" : "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ level, message }) => `${level}: ${message}`),
|
||||
),
|
||||
transports: [new winston.transports.Console(), createStream(filepath)],
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
|
||||
const logger = winston.createLogger({
|
||||
level: debug ? "debug" : "info",
|
||||
transports: [createStream(filepath)],
|
||||
});
|
||||
logger.on("logged", (info) => {
|
||||
switch (info.level) {
|
||||
case "debug":
|
||||
if (debug) core.debug(info.message);
|
||||
break;
|
||||
case "info":
|
||||
core.info(info.message);
|
||||
break;
|
||||
case "warn":
|
||||
core.warning(info.message);
|
||||
break;
|
||||
case "error":
|
||||
core.error(info.message);
|
||||
break;
|
||||
default:
|
||||
core.error(`[UNKNOWN LEVEL]: ${info.level}, message: ${info.message}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
export class Instruments {
|
||||
readonly cacheDir: string;
|
||||
readonly debug: boolean;
|
||||
readonly artifacts: Artifacts;
|
||||
readonly appLogger: winston.Logger;
|
||||
readonly execLogger: winston.Logger;
|
||||
|
||||
constructor(cacheDir: string, debug: boolean, artifacts?: Artifacts) {
|
||||
this.cacheDir = cacheDir;
|
||||
this.debug = debug;
|
||||
|
||||
this.artifacts =
|
||||
artifacts ??
|
||||
new Artifacts(cacheDir, [
|
||||
APP_LOG_FILENAME,
|
||||
EXEC_LOG_FILENAME,
|
||||
CACHE_KEY_FILENAME,
|
||||
MANIFEST_MAIN_FILENAME,
|
||||
MANIFEST_ALL_FILENAME,
|
||||
]);
|
||||
this.appLogger = createGitHubLogger(
|
||||
debug,
|
||||
path.join(cacheDir, APP_LOG_FILENAME),
|
||||
);
|
||||
this.execLogger = createExecLogger(
|
||||
debug,
|
||||
path.join(cacheDir, EXEC_LOG_FILENAME),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
src/io.ts
106
src/io.ts
|
|
@ -1,106 +0,0 @@
|
|||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type CommandRunner } from "../node_modules/ts-apt/dist/index.js";
|
||||
|
||||
const FORCE_UPDATE_INCREMENT = "4";
|
||||
const CACHE_DIRNAME = "cache-apt-pkgs";
|
||||
const CACHE_PREFIX = "cache-apt-pkgs_";
|
||||
|
||||
export function isAptListsFresh(): boolean {
|
||||
const aptListsPath = "/var/lib/apt/lists";
|
||||
const maxDepth = 5;
|
||||
|
||||
function search(currentPath: string, currentDepth: number): boolean {
|
||||
if (currentDepth > maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(currentPath);
|
||||
if (stats.isDirectory()) {
|
||||
const entries = fs.readdirSync(currentPath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry);
|
||||
if (search(fullPath, currentDepth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission errors or inaccessible paths.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return search(aptListsPath, 0);
|
||||
}
|
||||
|
||||
export class Package {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly version: string,
|
||||
) {}
|
||||
|
||||
serialize(): string {
|
||||
return `${this.name}@${this.version}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class CacheKey {
|
||||
constructor(
|
||||
readonly version: string,
|
||||
readonly forceUpdateIncrement: string,
|
||||
readonly arch: string,
|
||||
readonly normalizedPackages: string[],
|
||||
) {}
|
||||
|
||||
serialize(): string {
|
||||
return `${this.version} | ${this.forceUpdateIncrement} | ${this.arch} | ${this.normalizedPackages.join(",")}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeCacheKey(serialized: string): CacheKey {
|
||||
const parts = serialized.split("|");
|
||||
if (parts.length !== 4) {
|
||||
throw new Error(`Invalid serialized cache key: ${serialized}`);
|
||||
}
|
||||
|
||||
const [version, forceUpdateIncrement, arch, normalizedPackagesStr] = parts;
|
||||
return new CacheKey(
|
||||
version!,
|
||||
forceUpdateIncrement!,
|
||||
arch!,
|
||||
normalizedPackagesStr!.split(","),
|
||||
);
|
||||
}
|
||||
|
||||
export class Cache {
|
||||
private readonly cachePath: string;
|
||||
private readonly commandRunner: CommandRunner;
|
||||
|
||||
constructor(cacheDir: string = CACHE_DIRNAME, commandRunner: CommandRunner) {
|
||||
this.cachePath = path.join(os.homedir(), cacheDir);
|
||||
this.commandRunner = commandRunner;
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.cachePath;
|
||||
}
|
||||
|
||||
async getKey(normalizedPackages: string[], version: string): Promise<string> {
|
||||
const architecture = (await this.commandRunner.run("arch")).stdout.trim();
|
||||
let value = `${normalizedPackages.join(" ")} @ ${version} ${FORCE_UPDATE_INCREMENT}`;
|
||||
|
||||
if (architecture !== "x86_64") {
|
||||
value = `${value} ${architecture}`;
|
||||
}
|
||||
|
||||
const hash = crypto.createHash("md5").update(value).digest("hex");
|
||||
return `${CACHE_PREFIX}${hash}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,68 @@
|
|||
import fs from "node:fs";
|
||||
import { CacheKey } from "./cache.ts";
|
||||
import { createPackageName, packageNameFromJSON } from "ts-apt/package.ts";
|
||||
import { PackageInfo, PackageName } from "ts-apt/types.ts";
|
||||
|
||||
export function writeManifest(filePath: string, entries: string[]): void {
|
||||
const normalized = [...entries]
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
|
||||
}
|
||||
export class ManifestEntry {
|
||||
readonly packageName: PackageName;
|
||||
readonly filepaths: string[];
|
||||
|
||||
export function readManifestAsCsv(filePath: string): string {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return "";
|
||||
constructor(packageName: PackageName, filepaths: string[] = []) {
|
||||
this.packageName = packageName;
|
||||
this.filepaths = [...filepaths].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
return fs
|
||||
.readFileSync(filePath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
static fromJSON(json: any): ManifestEntry {
|
||||
return new ManifestEntry(
|
||||
packageNameFromJSON(json.packageName),
|
||||
json.filepaths as string[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Manifest {
|
||||
constructor(
|
||||
public readonly created: Date,
|
||||
public readonly entries: ManifestEntry[],
|
||||
public readonly cacheKey: CacheKey,
|
||||
) {}
|
||||
|
||||
// Static factory method
|
||||
static fromJSON(json: any): Manifest {
|
||||
return new Manifest(
|
||||
new Date(json.created),
|
||||
json.entries.map(
|
||||
(e: any) =>
|
||||
new ManifestEntry(
|
||||
packageNameFromJSON(e.packageName),
|
||||
e.filepaths as string[],
|
||||
),
|
||||
),
|
||||
CacheKey.fromJSON(json.cacheKey),
|
||||
);
|
||||
}
|
||||
|
||||
static from(
|
||||
date: Date,
|
||||
cacheKey: CacheKey,
|
||||
packageInfos: PackageInfo[],
|
||||
): Manifest {
|
||||
const entries = packageInfos.map(
|
||||
(info) =>
|
||||
new ManifestEntry(createPackageName(info.name, info.version), []),
|
||||
);
|
||||
return new Manifest(date, entries, cacheKey);
|
||||
}
|
||||
|
||||
async readFromFile(filePath: string): Promise<Manifest> {
|
||||
return Manifest.fromJSON(await fs.promises.readFile(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
async writeToFile(filePath: string): Promise<void> {
|
||||
await fs.promises.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(this, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
src/packages.ts
Normal file
187
src/packages.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import * as fs from "fs";
|
||||
import type { PackageName, PackageManager } from "ts-apt/types.ts";
|
||||
import path from "path";
|
||||
import { createPackageName, deserializePackageName } from "ts-apt/package.ts";
|
||||
import winston from "winston";
|
||||
|
||||
/**
|
||||
* Collects installed file and lifecycle script paths for archive creation.
|
||||
*
|
||||
* @param packageManager ts-apt package manager instance.
|
||||
* @param packageName Package descriptor.
|
||||
* @returns Sorted unique file list suitable for tar archiving.
|
||||
*/
|
||||
export async function buildFileList(
|
||||
packageName: PackageName,
|
||||
packageManager: PackageManager,
|
||||
): Promise<string[]> {
|
||||
// Converts absolute paths to tar-relative paths.
|
||||
const tarRelativePath = (filePath: string) =>
|
||||
filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
||||
|
||||
const files = (await packageManager.listInstalledFiles(packageName))
|
||||
.filter((filePath) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(filePath);
|
||||
return stat.isFile() || stat.isSymbolicLink();
|
||||
})
|
||||
.map((filePath) => tarRelativePath(filePath));
|
||||
|
||||
const preinst = await findInstallScript(packageName, "preinst", "/");
|
||||
const postinst = await findInstallScript(packageName, "postinst", "/");
|
||||
|
||||
if (preinst) {
|
||||
files.push(tarRelativePath(preinst));
|
||||
}
|
||||
if (postinst) {
|
||||
files.push(tarRelativePath(postinst));
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds package lifecycle install scripts in dpkg metadata when present.
|
||||
*
|
||||
* @param packageName Package name to resolve scripts for.
|
||||
* @param extension Script extension to resolve.
|
||||
* @param root Root filesystem path used to resolve dpkg metadata.
|
||||
* @returns Absolute script path when found.
|
||||
*/
|
||||
export async function findInstallScript(
|
||||
packageName: PackageName,
|
||||
extension: "preinst" | "postinst",
|
||||
root: string,
|
||||
): Promise<string | undefined> {
|
||||
const scriptsDir = path.join(root, "var", "lib", "dpkg", "info");
|
||||
if (!fs.existsSync(scriptsDir)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(
|
||||
`^${packageName.serialize()}(:.*)?\\.${extension}$`,
|
||||
);
|
||||
const matches = fs
|
||||
.readdirSync(scriptsDir)
|
||||
.filter((entry) => pattern.test(entry))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const candidate = matches[0];
|
||||
if (!candidate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path.join(scriptsDir, candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a concrete version for an unpinned package name.
|
||||
*
|
||||
* @param packageManager ts-apt package manager instance used for metadata lookup.
|
||||
* @param packageName Package name with or without a version pin.
|
||||
* @returns Resolved package version.
|
||||
* @throws Error when no version can be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersion(
|
||||
packageManager: PackageManager,
|
||||
packageName: PackageName,
|
||||
): Promise<string> {
|
||||
const packageInfo = await packageManager.getPackageInfo([packageName]);
|
||||
const version = packageInfo[0]?.version;
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`Unable to resolve package version for '${packageName.serialize()}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
export class ActionPackageNames {
|
||||
private readonly items: PackageName[];
|
||||
|
||||
private constructor(items: PackageName[]) {
|
||||
this.items = items.sort();
|
||||
}
|
||||
|
||||
static fromInput(serializedPackageNames: string): ActionPackageNames {
|
||||
const names = serializedPackageNames
|
||||
.replace(/[,\\]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((part) => deserializePackageName(part.trim()));
|
||||
return new ActionPackageNames(names);
|
||||
}
|
||||
|
||||
static fromJSON(json: any): ActionPackageNames {
|
||||
const items = (json as any[]).map((item: any) =>
|
||||
createPackageName(item.name, item.version, item.rc),
|
||||
);
|
||||
return new ActionPackageNames(items);
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
toArray(): ReadonlyArray<PackageName> {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates apt lists only when the local lists directory appears stale.
|
||||
*
|
||||
* @param packageManager ts-apt package manager instance.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
export async function updateAptLists(
|
||||
packageManager: PackageManager,
|
||||
logger: winston.Logger,
|
||||
): Promise<void> {
|
||||
const aptListsPath = "/var/lib/apt/lists";
|
||||
const maxDepth = 5;
|
||||
|
||||
const search = async (
|
||||
currentPath: string,
|
||||
currentDepth: number,
|
||||
): Promise<boolean> => {
|
||||
if (currentDepth > maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(currentPath);
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.promises.readdir(currentPath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry);
|
||||
if (await search(fullPath, currentDepth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission errors or inaccessible paths.
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await search(aptListsPath, 0)) {
|
||||
logger.info(
|
||||
`Apt lists directory '${aptListsPath}' appears to contain files, skipping 'apt update'`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Apt lists directory '${aptListsPath}' appears stale or empty, running 'apt update'...`,
|
||||
);
|
||||
await packageManager.update();
|
||||
}
|
||||
380
test/action-runner-coverage.test.ts
Normal file
380
test/action-runner-coverage.test.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import winston from "winston";
|
||||
|
||||
vi.mock("@actions/cache", () => ({
|
||||
restoreCache: vi.fn(),
|
||||
saveCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("ts-apt", () => ({
|
||||
createPackageManager: vi.fn(),
|
||||
DefaultCommandRunner: vi.fn().mockImplementation(() => ({
|
||||
run: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import * as cacheMod from "@actions/cache";
|
||||
import { createPackageManager, DefaultCommandRunner } from "ts-apt";
|
||||
import { Manifest } from "../src/manifest.js";
|
||||
import { CacheKey } from "../src/cache.ts";
|
||||
import {
|
||||
ActionPackageName,
|
||||
ActionRunner,
|
||||
runAction as runActionEntry,
|
||||
} from "../src/action.js";
|
||||
|
||||
describe("ActionRunner coverage", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createRunner() {
|
||||
const commandRunner = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const tarModule = {
|
||||
create: vi.fn(),
|
||||
extract: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
runner: new ActionRunner(
|
||||
commandRunner as never,
|
||||
tarModule as never,
|
||||
logger,
|
||||
),
|
||||
commandRunner,
|
||||
tarModule,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
it("parseBoolean invalid value expected throw", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.parseBoolean("invalid", "debug")).toThrow(
|
||||
/must be either true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizeInputPackages escaped separators expected sorted output", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.normalizeInputPackages(" z, a \\ b ")).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("validateEmptyPackages ignore empty expected no throw", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.validateEmptyPackages("ignore", [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("findInstallScript missing directory expected undefined", () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
|
||||
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findInstallScript matching scripts expected first sorted path", () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([
|
||||
"curl:amd64.preinst",
|
||||
"curl.preinst",
|
||||
"curl.postinst",
|
||||
] as never);
|
||||
|
||||
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBe(
|
||||
"/tmp/var/lib/dpkg/info/curl:amd64.preinst",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildFileListForPackage mixed files and scripts expected unique sorted tar paths", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
listInstalledFiles: vi
|
||||
.fn()
|
||||
.mockResolvedValue(["/a", "/b", "/missing", "/a"]),
|
||||
};
|
||||
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((p) => p !== "/missing");
|
||||
vi.spyOn(fs, "lstatSync").mockImplementation(
|
||||
(p) =>
|
||||
({
|
||||
isFile: () => p === "/a",
|
||||
isSymbolicLink: () => p === "/b",
|
||||
}) as fs.Stats,
|
||||
);
|
||||
vi.spyOn(runner, "findInstallScript")
|
||||
.mockReturnValueOnce("/var/lib/dpkg/info/curl.preinst")
|
||||
.mockReturnValueOnce("/var/lib/dpkg/info/curl.postinst");
|
||||
|
||||
await expect(
|
||||
runner.buildFileListForPackage(
|
||||
packageManager as never,
|
||||
new ActionPackageName("curl"),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"var/lib/dpkg/info/curl.postinst",
|
||||
"var/lib/dpkg/info/curl.preinst",
|
||||
]);
|
||||
});
|
||||
|
||||
it("installAndCachePackages new archives expected tar create and manifests", async () => {
|
||||
const { runner, tarModule } = createRunner();
|
||||
const packageManager = {
|
||||
install: vi.fn().mockResolvedValue([{ name: "curl", version: "1.0" }]),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.spyOn(runner, "updateAptLists").mockResolvedValue(undefined);
|
||||
vi.spyOn(runner, "buildFileListForPackage").mockResolvedValue([
|
||||
"usr/bin/curl",
|
||||
]);
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
|
||||
await runner.installAndCachePackages(
|
||||
"/cache",
|
||||
[new ActionPackageName("curl")],
|
||||
packageManager as never,
|
||||
new CacheKey("", "4", "x86_64", ["curl"]),
|
||||
);
|
||||
|
||||
expect(tarModule.create).toHaveBeenCalledOnce();
|
||||
expect(fs.existsSync("/cache/manifest_main.json")).toBe(true);
|
||||
expect(fs.existsSync("/cache/manifest_all.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("restorePackages install scripts enabled expected extract and script runs", async () => {
|
||||
const { runner, commandRunner, tarModule } = createRunner();
|
||||
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([
|
||||
"curl=1.0.tar",
|
||||
"notes.txt",
|
||||
] as never);
|
||||
vi.spyOn(runner, "findInstallScript")
|
||||
.mockReturnValueOnce("/preinst")
|
||||
.mockReturnValueOnce("/postinst");
|
||||
|
||||
await runner.restorePackages("/cache", true);
|
||||
|
||||
expect(tarModule.extract).toHaveBeenCalledWith({
|
||||
cwd: "/",
|
||||
file: "/cache/curl=1.0.tar",
|
||||
preservePaths: true,
|
||||
});
|
||||
expect(commandRunner.run).toHaveBeenNthCalledWith(1, "sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
"/preinst",
|
||||
"install",
|
||||
]);
|
||||
expect(commandRunner.run).toHaveBeenNthCalledWith(2, "sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
"/postinst",
|
||||
"configure",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runAction version contains spaces expected throw", async () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "bad version",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).rejects.toThrow(/cannot contain spaces/);
|
||||
});
|
||||
|
||||
it("runAction empty normalized packages expected empty outputs and manifests", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-empty-cache");
|
||||
|
||||
vi.mocked(createPackageManager).mockResolvedValue({} as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "",
|
||||
version: "",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "ignore",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: false,
|
||||
packageVersionList: "",
|
||||
allPackageVersionList: "",
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(cacheDir, "manifest_main.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(cacheDir, "manifest_all.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("runAction cache miss expected install and save cache", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-cache-miss");
|
||||
|
||||
vi.mocked(createPackageManager)
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({ install: vi.fn() } as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
|
||||
new ActionPackageName("curl", "1.0"),
|
||||
]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(runner, "installAndCachePackages").mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.restoreCache).mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.saveCache).mockResolvedValue(1);
|
||||
vi.spyOn(Manifest, "readFromFile")
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ name: "curl", version: "1.0", filepaths: [] },
|
||||
{ name: "dep", version: "1.0", filepaths: [] },
|
||||
],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: false,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
});
|
||||
|
||||
expect(runner.installAndCachePackages).toHaveBeenCalledOnce();
|
||||
expect(cacheMod.saveCache).toHaveBeenCalledWith(
|
||||
[cacheDir],
|
||||
"cache-apt-pkgs_key",
|
||||
);
|
||||
});
|
||||
|
||||
it("runAction cache hit expected restore packages", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-cache-hit");
|
||||
|
||||
vi.mocked(createPackageManager).mockResolvedValue({} as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
|
||||
new ActionPackageName("curl", "1.0"),
|
||||
]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(runner, "restorePackages").mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.restoreCache).mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(Manifest, "readFromFile")
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ name: "curl", version: "1.0", filepaths: [] },
|
||||
{ name: "dep", version: "1.0", filepaths: [] },
|
||||
],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: true,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: true,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
});
|
||||
|
||||
expect(runner.restorePackages).toHaveBeenCalledWith(cacheDir, true);
|
||||
});
|
||||
|
||||
it("runAction wrapper valid inputs expected delegated runner output", async () => {
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const delegated = {
|
||||
cacheHit: false,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
};
|
||||
|
||||
const runSpy = vi
|
||||
.spyOn(ActionRunner.prototype, "runAction")
|
||||
.mockResolvedValue(delegated);
|
||||
|
||||
await expect(
|
||||
runActionEntry(
|
||||
{
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
},
|
||||
{ run: vi.fn() } as never,
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(delegated);
|
||||
|
||||
expect(DefaultCommandRunner).toHaveBeenCalledWith(logger, logger);
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
119
test/action-runner.test.ts
Normal file
119
test/action-runner.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import winston from "winston";
|
||||
|
||||
import { ActionRunner } from "../src/action.js";
|
||||
|
||||
describe("ActionRunner", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createRunner() {
|
||||
const commandRunner = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const tarModule = {
|
||||
create: vi.fn(),
|
||||
extract: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
runner: new ActionRunner(
|
||||
commandRunner as never,
|
||||
tarModule as never,
|
||||
logger,
|
||||
),
|
||||
commandRunner,
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves package versions from package manager metadata", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
getPackageInfo: vi.fn().mockResolvedValue([{ version: "1.2.3" }]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runner.resolvePackageVersion(packageManager as never, "git"),
|
||||
).resolves.toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("fails when package metadata does not contain a version", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
getPackageInfo: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runner.resolvePackageVersion(packageManager as never, "git"),
|
||||
).rejects.toThrow(/Unable to resolve package version/);
|
||||
});
|
||||
|
||||
it("normalizes packages and fills in missing versions", async () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(runner, "resolvePackageVersion")
|
||||
.mockResolvedValueOnce("8.0")
|
||||
.mockResolvedValueOnce("2.39");
|
||||
|
||||
await expect(
|
||||
runner.normalizePackagesWithVersions({} as never, "git curl=8.1"),
|
||||
).resolves.toEqual(["curl=8.1", "git=8.0"]);
|
||||
});
|
||||
|
||||
it("warns instead of throwing for empty packages when behavior is warn", () => {
|
||||
const { runner } = createRunner();
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
expect(() => runner.validateEmptyPackages("warn", [])).not.toThrow();
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
"::warning::Packages argument is empty.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for empty packages when behavior is error", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.validateEmptyPackages("error", [])).toThrow(
|
||||
/Packages argument is empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the cache root under the current home directory", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.getCacheRoot()).toBe(
|
||||
path.join(os.homedir(), "cache-apt-pkgs"),
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes runner cache keys using the current architecture", async () => {
|
||||
const { runner, commandRunner } = createRunner();
|
||||
commandRunner.run.mockResolvedValue({ stdout: "arm64\n" });
|
||||
|
||||
await expect(runner.getCacheKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
"cache-apt-pkgs_36cfe31d08e34e7bd87b39c0e0145ece",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes versions from package specifiers", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.packageSpecifierToName("curl=8.1")).toBe("curl");
|
||||
expect(runner.packageSpecifierToName("git")).toBe("git");
|
||||
});
|
||||
|
||||
it("strips leading slashes from tar paths", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.tarRelativePath("/var/cache/apt/pkg.tar")).toBe(
|
||||
"var/cache/apt/pkg.tar",
|
||||
);
|
||||
expect(runner.tarRelativePath("relative/file")).toBe("relative/file");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeInputPackages, parseBoolean } from "../src/action.js";
|
||||
import {
|
||||
ActionPackageName,
|
||||
normalizeInputPackages,
|
||||
parseBoolean,
|
||||
} from "../src/action.js";
|
||||
|
||||
describe("non runner functions", () => {
|
||||
const PKG_NAME = "curl";
|
||||
const PKG_VER = "1.2.3";
|
||||
const PKG_DISTRO = "focal";
|
||||
const PKG2_NAME = "git";
|
||||
const PKG3_NAME = "jq";
|
||||
|
||||
describe("action utils", () => {
|
||||
it("normalizes package list syntax", () => {
|
||||
const input = " git, curl \\\n jq ";
|
||||
expect(normalizeInputPackages(input)).toEqual(["curl", "git", "jq"]);
|
||||
const input = ` ${PKG2_NAME}, ${PKG_NAME} \\\n ${PKG3_NAME} `;
|
||||
expect(normalizeInputPackages(input)).toEqual([
|
||||
PKG_NAME,
|
||||
PKG2_NAME,
|
||||
PKG3_NAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses true/false values", () => {
|
||||
|
|
@ -15,4 +29,20 @@ describe("action utils", () => {
|
|||
it("fails for invalid booleans", () => {
|
||||
expect(() => parseBoolean("TRUE", "debug")).toThrow();
|
||||
});
|
||||
|
||||
it("serializes ActionPackageName with no version", () => {
|
||||
expect(new ActionPackageName(PKG_NAME).serialize()).toEqual(PKG_NAME);
|
||||
});
|
||||
|
||||
it("serializes ActionPackageName with version", () => {
|
||||
expect(new ActionPackageName(PKG_NAME, PKG_VER).serialize()).toEqual(
|
||||
`${PKG_NAME}=${PKG_VER}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes ActionPackageName with version and distro", () => {
|
||||
expect(
|
||||
new ActionPackageName(PKG_NAME, PKG_VER, PKG_DISTRO).serialize(),
|
||||
).toEqual(`${PKG_NAME}=${PKG_VER}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
93
test/io.test.ts
Normal file
93
test/io.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Cache, CacheKey, isAptListsFresh } from "../src/cache.ts";
|
||||
import { ActionPackageName } from "../src/action.ts";
|
||||
|
||||
describe("io", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects fresh apt lists when a file exists within search depth", () => {
|
||||
vi.spyOn(fs, "statSync").mockImplementation(
|
||||
(currentPath: fs.PathLike) =>
|
||||
({
|
||||
isDirectory: () =>
|
||||
String(currentPath) !== "/var/lib/apt/lists/partial/pkg.idx",
|
||||
}) as fs.Stats,
|
||||
);
|
||||
|
||||
vi.spyOn(fs, "readdirSync")
|
||||
.mockImplementationOnce(() => ["partial"] as never)
|
||||
.mockImplementationOnce(() => ["pkg.idx"] as never);
|
||||
|
||||
expect(isAptListsFresh()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a boolean for the current system apt list state", () => {
|
||||
expect(typeof isAptListsFresh()).toBe("boolean");
|
||||
});
|
||||
|
||||
it("serializes package values", () => {
|
||||
expect(new ActionPackageName("git", "1.2.3").serialize()).toBe("git@1.2.3");
|
||||
});
|
||||
|
||||
it("serializes and deserializes cache keys", () => {
|
||||
const key = new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]);
|
||||
expect(key.serialize()).toBe("v1 | 4 | arm64 | curl=1,git=2");
|
||||
|
||||
expect(CacheKey.deserialize(key.serialize())).toEqual(
|
||||
new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid serialized cache keys", () => {
|
||||
expect(() => CacheKey.deserialize("invalid")).toThrow(
|
||||
/Invalid serialized cache key/,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds cache paths under the current home directory", () => {
|
||||
const cache = new Cache("custom-cache", {
|
||||
run: vi.fn(),
|
||||
} as never);
|
||||
|
||||
expect(cache.path).toBe(path.join(os.homedir(), "custom-cache"));
|
||||
});
|
||||
|
||||
it("hashes cache keys without appending the default x86_64 architecture", async () => {
|
||||
const commandRunner = {
|
||||
run: vi.fn().mockResolvedValue({ stdout: "x86_64\n" }),
|
||||
};
|
||||
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
const expectedHash = crypto
|
||||
.createHash("md5")
|
||||
.update("curl=1 git=2 @ 'v1' 4")
|
||||
.digest("hex");
|
||||
|
||||
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
`cache-apt-pkgs_${expectedHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes cache keys with non-default architectures", async () => {
|
||||
const commandRunner = {
|
||||
run: vi.fn().mockResolvedValue({ stdout: "arm64\n" }),
|
||||
};
|
||||
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
const expectedHash = crypto
|
||||
.createHash("md5")
|
||||
.update("curl=1 git=2 @ 'v1' 4 arm64")
|
||||
.digest("hex");
|
||||
|
||||
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
`cache-apt-pkgs_${expectedHash}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,21 +2,47 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readManifestAsCsv, writeManifest } from "../src/manifest.js";
|
||||
import { Manifest, ManifestEntry } from "../src/manifest.js";
|
||||
|
||||
describe("manifest", () => {
|
||||
it("writes sorted entries and reads csv output", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-test-"));
|
||||
const filePath = path.join(tempDir, "manifest.log");
|
||||
it("serializes and deserializes manifest entries", () => {
|
||||
const manifest = new Manifest(
|
||||
[
|
||||
new ManifestEntry("z", "2", undefined, ["/b", "/a"]),
|
||||
new ManifestEntry("a", "1", undefined, []),
|
||||
],
|
||||
"input",
|
||||
"cache-key",
|
||||
"4",
|
||||
"x86_64",
|
||||
);
|
||||
|
||||
writeManifest(filePath, ["z=2", "a=1", "m=9"]);
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf8")).toBe("a=1\nm=9\nz=2");
|
||||
expect(readManifestAsCsv(filePath)).toBe("a=1,m=9,z=2");
|
||||
const parsed = Manifest.deserialize(manifest.serialize());
|
||||
expect(parsed.entries[0]?.name).toBe("z");
|
||||
expect(parsed.cacheKey).toBe("cache-key");
|
||||
});
|
||||
|
||||
it("returns empty csv for missing files", () => {
|
||||
it("writes and reads manifest files", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-test-"));
|
||||
const filePath = path.join(tempDir, "manifest.json");
|
||||
const manifest = new Manifest(
|
||||
[new ManifestEntry("curl", "8.1", undefined, ["usr/bin/curl"])],
|
||||
"input",
|
||||
"cache-key",
|
||||
"4",
|
||||
"x86_64",
|
||||
);
|
||||
|
||||
manifest.writeToFile(filePath);
|
||||
const parsed = Manifest.readFromFile(filePath);
|
||||
expect(parsed.entries).toHaveLength(1);
|
||||
expect(parsed.entries[0]?.name).toBe("curl");
|
||||
});
|
||||
|
||||
it("throws for missing files", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-missing-"));
|
||||
expect(readManifestAsCsv(path.join(tempDir, "none.log"))).toBe("");
|
||||
expect(() =>
|
||||
Manifest.readFromFile(path.join(tempDir, "none.json")),
|
||||
).toThrow(/Manifest file not found/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"paths": {
|
||||
"ts-apt": [
|
||||
"./node_modules/ts-apt/src/index.ts"
|
||||
],
|
||||
"ts-apt/*": [
|
||||
"./node_modules/ts-apt/src/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"declarationMap": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"scripts/**/*.mts",
|
||||
"scripts/dist/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
|
@ -13,6 +15,7 @@
|
|||
"node",
|
||||
"vitest/globals"
|
||||
],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
21
tsconfig.scripts.json
Normal file
21
tsconfig.scripts.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"include": [
|
||||
"scripts/**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"scripts/dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ export default defineConfig({
|
|||
provider: "v8",
|
||||
reporter: ["text", "lcov", "json"],
|
||||
reportsDirectory: "coverage",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/index.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue