ci: bundle the action with rollup and upload it for integration jobs (#233)

* ci: bundle the action with rollup and upload it for integration jobs

The integration jobs download an action-bundle-<sha> artifact that
nothing built or uploaded, so all of them failed on their first step.
v2 also had no bundle: action.yml runs dist/index.js, and an action has
no node_modules at runtime.

- Add rollup.config.mjs, which bundles the tsc output (dist/src) into a
  self-contained dist/index.js. npm run build now runs it.
- Fix the setup action, which never built: with if_no_artifact_found:
  ignore the download step always succeeds, so check found_artifact.
  Download cached builds into dist/, and overwrite the build artifact
  since several jobs in a run can build it.
- Upload action.yml and the bundle as action-bundle-<sha> from
  setup-and-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chor: remove stale file

* fix: write logs under the home cache directory and await artifact upload

Instruments opened its log files at the relative path cache-apt-pkgs/
before that directory existed, so the action crashed at startup with
ENOENT. It also pointed artifact uploads there while Cache uses
~/cache-apt-pkgs. Resolve both with a shared resolveCacheDir helper and
create the directory before opening the logs.

The artifact upload was also never awaited, so a failed upload became an
unhandled rejection. Await it and report failures as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: ship ts-apt's apt-fast script with the bundle

On runners with aria2 installed, ts-apt uses apt-fast and spawns
scripts/dist/apt-fast.sh relative to its own module. Bundling moved that
module, so the action failed with "spawn .../scripts/dist/apt-fast.sh
ENOENT".

Write the bundle to dist/action/index.js and copy ts-apt's scripts/dist
to dist/scripts/dist, which is where ts-apt looks from there. Point
action.yml at the new entrypoint and include both in the action bundle
artifact. Workflow artifacts drop file modes, so the action marks the
script executable at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: pin ts-apt to e627c3b for install timeout and dpkg-query fixes

ts-apt killed every apt command after 30 seconds, so installs failed
with exit code 124, and listInstalledFiles passed name=version to
dpkg-query -L, which rejects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: make diagnostic artifact names unique per step

Every job in a run uploaded capa_artifacts-<run id>, so only the first
upload succeeded. Add the run attempt, job and step IDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run cache restore jobs after the jobs that create the cache

The restore and add-package jobs all only needed testing, so they ran in
parallel with standard_workflow_install and raced on the same cache key.
Restore the ordering v1 uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: authenticate the action pin check to avoid API rate limits

The check called the GitHub API anonymously and hit the rate limit for
the shared runner IP. The script uses GITHUB_TOKEN when it's set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: pin ts-apt to 342c7d0 for native arch and stdin input

Adds PackageManager.getNativeArchitecture and CommandOptions.input, used by the version-list outputs and archive creation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: report requested and dependency package versions separately

package-version-list held every installed package and both outputs were
identical. Outputs also carried arch suffixes inconsistently and followed
install order.

- package-version-list lists only requested packages; all-package-version-list
  lists every installed package.
- Entries are name=version, with :arch only for packages installed for a
  non-native architecture (e.g. libc6:i386), so native output matches v1.
- Entries are sorted with PackageName.compareTo.
- Requested packages are matched by name, and by arch when both specify one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: archive and restore packages with system tar under sudo

The in-process tar library ran as the runner user, so archiving failed on
root-only files such as /etc/default/cacerts from ca-certificates-java
(regression #72), and extraction could skip root-owned paths without failing.

- Create and extract with sudo tar; the file list is piped NUL-separated on
  stdin so large packages don't exceed the argument size limit.
- Keep directory symlinks such as /lib on extract.
- Hand archives back to the runner user for the cache save.
- Disable the command timeout for tar and maintainer scripts.
- Record the installed (versioned) entries in manifest_main.json; it held
  unversioned names with no files.
- Return every installed package on a cache hit so outputs include deps.
- Split runInstallScripts into per-script runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: update package expectations for Ubuntu 24.04

libasound2 is libasound2t64 on 24.04 (regression #81), and gir1.2-gdkpixbuf-2.0 moved to 2.42.10+dfsg-3ubuntu3.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: add ts-apt boundary rules to AGENTS.md

APT and dpkg logic belongs in ts-apt; ts-apt changes are pushed before the action bumps its pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: pin version-list jobs to ubuntu-24.04 and fix spaces-in-paths tests

- list_versions / list_all_versions: run on ubuntu-24.04. The expected
  versions are from 24.04, but ubuntu-latest now resolves newer ones
  (e.g. rolldice=1.16-1build4, xdot=1.6-1).
- regression_76: cache libopenal-data ("Default HRTF.mhr") instead of
  Intel oneAPI. Drops the Intel repo and the unused opencl and
  libsundials-dev installs (188 MB, 155 packages), which made the job
  take 75+ minutes when the Ubuntu mirror was slow. Cap at 15 minutes.
- regression_81: list alsa-ucm-conf explicitly so the test caches a
  package with spaces in its file paths.

Same fixes as #235 and #236 on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrew Walsh 2026-11-07 12:39:31 -08:00 committed by GitHub
parent 16d0f3489d
commit a84878c774
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 762 additions and 335 deletions

View file

@ -27,17 +27,22 @@ runs:
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
name: ${{ env.build-artifacts-name }}
path: dist
if_no_artifact_found: ignore
# With if_no_artifact_found: ignore the download step always succeeds, so
# check found_artifact instead of the step outcome.
- name: Build
if: steps.try-download-artifact.outcome == 'failure'
if: steps.try-download-artifact.outputs.found_artifact != 'true'
shell: bash
run: npm run build # Generates dist/ folder
run: npm run build # Generates dist/ folder, including the dist/action/index.js bundle
- name: Upload Build Artifacts
if: steps.try-download-artifact.outcome == 'failure'
if: steps.try-download-artifact.outputs.found_artifact != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.build-artifacts-name }}
path: dist
retention-days: 1
# Jobs in the same run each build when no earlier run has the artifact.
overwrite: true

View file

@ -41,6 +41,18 @@ jobs:
with:
operation: build
# The integration jobs below run this bundle as the action (uses: ./).
- name: Upload Action Bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: action-bundle-${{ github.event.pull_request.head.sha || github.sha }}
path: |
action.yml
dist/action/
dist/scripts/
if-no-files-found: error
retention-days: 1
codeql:
# Fork PRs do not have permission to upload security events.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
@ -107,6 +119,9 @@ jobs:
- name: Check Action Dependencies Pinned
shell: bash
env:
# Authenticated API requests avoid the shared runner IP rate limit.
GITHUB_TOKEN: ${{ github.token }}
run: npm run check:latest-action-pins
testing:
@ -149,7 +164,7 @@ jobs:
# All other jobs should depend on build_binaries (if run) or check_distribute (if not needed)
list_all_versions:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04 # expected versions below are from Ubuntu 24.04
needs: [testing]
name: List all package versions (including deps).
steps:
@ -168,18 +183,18 @@ jobs:
- name: Verify
if: |
steps.execute.outputs.cache-hit != 'false' ||
steps.execute.outputs.all-package-version-list != 'fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.2,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1'
steps.execute.outputs.all-package-version-list != 'fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.3,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1'
run: |
echo "cache-hit = ${{ steps.execute.outputs.cache-hit }}"
echo "package-version-list = ${{ steps.execute.outputs.package-version-list }}"
echo "all-package-version-list = ${{ steps.execute.outputs.all-package-version-list }}"
echo "diff all-package-version-list"
diff <(echo "${{ steps.execute.outputs.all-package-version-list }}" ) <(echo "fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.2,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1")
diff <(echo "${{ steps.execute.outputs.all-package-version-list }}" ) <(echo "fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.3,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1")
exit 1
shell: bash
list_versions:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04 # expected versions below are from Ubuntu 24.04
needs: [testing]
name: List package versions.
steps:
@ -233,7 +248,7 @@ jobs:
standard_workflow_install_with_new_version:
runs-on: ubuntu-latest
needs: [testing]
needs: [standard_workflow_install]
name: Standard workflow packages with new version.
steps:
- name: Download Action Bundle
@ -257,7 +272,7 @@ jobs:
standard_workflow_restore:
runs-on: ubuntu-latest
needs: [testing]
needs: [standard_workflow_install]
name: Standard workflow restore cached packages.
steps:
- name: Download Action Bundle
@ -281,7 +296,7 @@ jobs:
standard_workflow_restore_with_packages_out_of_order:
runs-on: ubuntu-latest
needs: [testing]
needs: [standard_workflow_install]
name: Standard workflow restore with packages out of order.
steps:
- name: Download Action Bundle
@ -305,7 +320,7 @@ jobs:
standard_workflow_add_package:
runs-on: ubuntu-latest
needs: [testing]
needs: [standard_workflow_install]
name: Standard workflow add another package.
steps:
- name: Download Action Bundle
@ -329,7 +344,7 @@ jobs:
standard_workflow_restore_add_package:
runs-on: ubuntu-latest
needs: [testing]
needs: [standard_workflow_add_package]
name: Standard workflow restore added package.
steps:
- name: Download Action Bundle
@ -500,22 +515,19 @@ jobs:
runs-on: ubuntu-latest
needs: [testing]
name: "Cache empty archive (regression issue #76)."
timeout-minutes: 15
steps:
- name: Download Action Bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: action-bundle-${{ github.event.pull_request.head.sha || github.sha }}
path: .
- run: |
sudo wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor | tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null;
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" | sudo tee /etc/apt/sources.list.d/oneAPI.list;
sudo apt-get -qq update;
sudo apt-get install -y intel-oneapi-runtime-libs intel-oneapi-runtime-opencl;
sudo apt-get install -y opencl-headers ocl-icd-opencl-dev;
sudo apt-get install -y libsundials-dev;
# The original report was an Intel oneAPI package whose files have spaces in their
# paths. libopenal-data ships "/usr/share/openal/hrtf/Default HRTF.mhr" and comes
# from the Ubuntu archive, so no third-party repo or extra installs are needed.
- uses: ./
with:
packages: intel-oneapi-runtime-libs
packages: libopenal-data
version: ${{ github.run_id }}-${{ github.run_attempt }}-regression_76
debug: ${{ env.DEBUG }}
@ -548,7 +560,7 @@ jobs:
- uses: ./
with:
packages:
libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcups2 libdrm2 libgbm1
alsa-ucm-conf libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcups2 libdrm2 libgbm1
libnspr4 libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2
version: ${{ github.run_id }}-${{ github.run_attempt }}-regression_81
debug: ${{ env.DEBUG }}

View file

@ -9,6 +9,7 @@ You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub
3. `Section 2` for tests.
4. `Section 3` for CI/CD and release work.
5. `Section 4` for repo-specific paths and commands.
6. `Section 5` before touching APT, dpkg, or package-name logic.
## Instruction Efficiency
@ -72,6 +73,20 @@ You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub
- 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.
## 5) ts-apt Boundary
- ts-apt owns APT and dpkg logic; its local clone is `../ts-apt`.
- MUST make changes in `../ts-apt` when they run `apt`, `apt-get`, `apt-cache`, `apt-fast`, `dpkg`, or `dpkg-query`.
- MUST make changes in `../ts-apt` when they parse APT or dpkg output or change `PackageName` behavior.
- NEVER call APT or dpkg binaries from `src/`; add a `PackageManager` method in ts-apt and call it.
- This repo owns cache keys, manifests, archives, action inputs, and action outputs.
- Follow `../ts-apt/AGENTS.md` when editing ts-apt.
- Add ts-apt unit tests for every new ts-apt method.
- Use `npm run dev:tsapt:link` to test against local ts-apt; run `npm run dev:tsapt:unlink` when finished.
- `package.json` pins ts-apt to a commit SHA; local ts-apt changes are not visible to CI.
- MUST push the ts-apt change first, then bump the ts-apt SHA pin in the same change that uses it.
- NEVER push action code that depends on an unpushed ts-apt change.
## Quick Commands
```bash

View file

@ -1,212 +0,0 @@
# AGENTS.md Metadata
IMPORTANT: THIS FILE IS NOT FOR AGENTIC AI
Metadata used in creation and update of agent role, behavior and directives.
## Creation
### Initial Prompt
Create `AGENTS.md` file text that includes these directives:
- You are an expert developer skilled in TypeScript, Bash, DevOps, Git, GitHub workflows, and GitHub Actions.
- Avoid sycophant behavior in replies and actions.
- Push back when needed and scrutinize requests for correctness and compliance with these guidelines.
Code must follow:
- Style guidance from the Microsoft TypeScript Style Guide and Shellcheck.
- Consistent naming for semantically equivalent classes, interfaces, types, methods, variables, and member fields.
- Consistent documentation for those naming targets.
- Always document exported types.
- Provide additional context for new readers where behavior is not obvious from signatures.
CI/CD must follow best practices:
- Reference: https://github.com/github/awesome-copilot/blob/main/instructions/github-actions-ci-cd-best-practices.instructions.md
- Abstract shared logic across workflows and actions.
- Treat `main` as the official release branch with no known bugs.
- Treat `staging` as the branch for experimental features.
Security and dependency guidance:
- Follow security best practices and the principle of least privilege.
- Use libraries consistently (for example: `semantic-release`, `vitest`).
- Follow recommended usage patterns from maintainers/authors.
Testing patterns must be consistent:
- Naming format: `<method> <conditions> <expected>`
- Focus on behavior under test.
- Keep setup directly tied to conditions/expected behavior.
- Use constants when exact values do not matter, only presence.
- Use parameterized tests or helper functions for repeated patterns.
- Do not use logic in tests.
For every code update:
- Run tests, fix failures, and iterate.
- Avoid infinite loops and explain the reason when stopping.
- 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
#### Agent Characteristics Optimization
**Prompt**
```
Optimize AGENTS.md for progressive discovery and avoiding context bloat that the models already are capable of. Assume the lowest common denominiter / capability evaluating all the models.
```
**\_Response**
```
I’m going to streamline AGENTS.md into a layered “read-minimum-first” format so lower-capability models can follow it without carrying unnecessary context. I’ll keep core guardrails and remove redundant detail.
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS_META.md)
I’m replacing AGENTS.md with a shorter layered version that starts with universal rules and only expands into domain-specific guidance when needed, to reduce token overhead for weaker models.
Generating patch (3 lines) in [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Created [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
The new layout is in place with progressive layers and trimmed redundancy. I’m doing a quick read-back to confirm formatting and that the guidance stayed aligned with your original constraints.
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Optimized AGENTS.md for progressive discovery and low-context model compatibility.
What changed:
- Replaced long narrative sections with a layered “read in order” structure.
- Put mandatory, model-agnostic rules in a minimal **Section 0**.
- Moved conditional guidance into scoped sections:
- coding
- testing
- CI/CD
- script placement
- Reduced redundancy and removed explanatory prose that models typically already infer.
- Kept your critical constraints intact:
- anti-sycophancy / push-back
- style and naming consistency
- test conventions and no logic in tests
- CI/CD abstraction and branch intent (`main` vs `staging`)
- security/least privilege
- ask-first / never-do guardrails
- Preserved quick command references in a compact block.
```
#### Always-on Rules
**Prompt**
```
- Explicitly include instruction-efficiency rules covering progressive discovery, salience control, and context-bloat reduction when creating/updating AGENTS.md.
- Add always-on rules for:
- irreversible action confirmation,
- execution side effects action confirmation,
- verification-only success claims,
- minimal/safe diffs,
- API stability by default,
- narrow-scope validation,
- bounded fix loops,
- secret-safe handling,
- least-privilege permissions,
- fail-fast error handling, and
- explicit assumptions.
```
**Response**
```
I’m going to streamline AGENTS.md into a layered “read-minimum-first” format so lower-capability models can follow it without carrying unnecessary context. I’ll keep core guardrails and remove redundant detail.
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS_META.md)
I’m replacing AGENTS.md with a shorter layered version that starts with universal rules and only expands into domain-specific guidance when needed, to reduce token overhead for weaker models.
Generating patch (3 lines) in [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Created [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
The new layout is in place with progressive layers and trimmed redundancy. I’m doing a quick read-back to confirm formatting and that the guidance stayed aligned with your original constraints.
Read [](file:///home/awalsh128/cache-apt-pkgs-action/AGENTS.md)
Optimized AGENTS.md for progressive discovery and low-context model compatibility.
What changed:
- Replaced long narrative sections with a layered “read in order” structure.
- Put mandatory, model-agnostic rules in a minimal **Section 0**.
- Moved conditional guidance into scoped sections:
- coding
- testing
- CI/CD
- script placement
- Reduced redundancy and removed explanatory prose that models typically already infer.
- Kept your critical constraints intact:
- anti-sycophancy / push-back
- style and naming consistency
- test conventions and no logic in tests
- CI/CD abstraction and branch intent (`main` vs `staging`)
- security/least privilege
- ask-first / never-do guardrails
- Preserved quick command references in a compact block.
```
#### Skills &amp; Rules
**Prompt**
```
Consider useful guidelines calling agent features like progressive discovery, salience, context bloat when creating or updating AGENTS.md and if it should be explicitly calling out in the file to speed up responses and reducing token usage
```
**Response**
```
Updated:
- AGENTS.md
- AGENTS_META.md
What was added:
- A dedicated Instruction Efficiency section in AGENTS.md with concrete rules for:
- progressive discovery layering
- salience control (limited emphasis, explicit priority keywords)
- context-bloat reduction (short core, scoped detail, no capability restatement)
- low-capability model optimization (simple, deterministic wording)
- A matching refinement directive in AGENTS_META.md so future regenerations preserve the same optimization intent.
This is the right approach for faster responses and lower token usage because it improves instruction ordering and reduces redundant prompt mass while keeping hard constraints explicit.
```
## Implementation Notes
### 1. Anti-Sycophancy Directive
The `Role & Behavior` section explicitly instructs the agent to **push back**and **scrutinize**. This prevents the common LLM tendency to agree with incorrect user premises. The agent is programmed to prioritize correctness over compliance with unsafe requests.
### 2. Directory Separation
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
The `Testing Strategy` section encodes your specific constraints:
- **Naming:**Enforces the `<method> <conditions> <expected>` pattern.
- **No Logic in Tests:**Explicitly forbids control flow within test bodies, ensuring tests remain simple assertions of state.
- **Iteration Limit:**The instruction to "stop and explain" after two failed fix attempts prevents agents from getting stuck in correction loops.
### 4. CI/CD Abstraction
The file mandates abstracting common logic from GitHub Actions YAML into reusable components, directly addressing your requirement to follow best practices and reduce duplication.

View file

@ -50,8 +50,8 @@ There are three kinds of version labels you can use.
### Outputs
- `cache-hit` - A boolean value to indicate a cache was found for the packages requested.
- `package-version-list` - The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \<package1>=<version1\>,\<package2>=\<version2>,...).
- `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \<package1>=<version1\>,\<package2>=\<version2>,...).
- `package-version-list` - The main requested packages and versions that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. \<package1>=\<version1>,\<package2>=\<version2>,...). Packages installed for a non-native architecture include it (i.e. \<package>:\<arch>=\<version>).
- `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. \<package1>=\<version1>,\<package2>=\<version2>,...). Packages installed for a non-native architecture include it (i.e. \<package>:\<arch>=\<version>).
### Security Compliance

View file

@ -37,10 +37,10 @@ outputs:
cache-hit:
description: "A boolean value to indicate a cache was found for the packages requested."
package-version-list:
description: "The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. <package>:<version,<package>:<version>)."
description: "The main requested packages and versions that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...). Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>)."
all-package-version-list:
description: "All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. <package>:<version,<package>:<version>)."
description: "All the pulled in packages and versions, including dependencies, that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...). Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>)."
runs:
using: "node24"
main: "dist/index.js"
main: "dist/action/index.js"

210
package-lock.json generated
View file

@ -15,12 +15,15 @@
"class-transformer": "^0.5.1",
"rollup": "^4.62.2",
"tar": "^7.4.3",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#4a0305c3fde1f42ea9a5c79ac53bf27fdf5d76c2",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#342c7d0f453dcf1a69da83d7d195f9df2611a351",
"winston": "^3.19.0"
},
"devDependencies": {
"@devcontainers/cli": "^0.87.0",
"@robingenz/zli": "^0.2.0",
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
@ -1413,6 +1416,160 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
"version": "29.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz",
"integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz",
"integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@ -2431,6 +2588,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/tar": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz",
@ -3945,6 +4109,13 @@
"node": ">=12.20"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/compare-func": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz",
@ -4290,6 +4461,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@ -6317,6 +6498,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
@ -6380,6 +6568,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -12318,8 +12516,8 @@
},
"node_modules/ts-apt": {
"version": "0.0.0-semantically-released",
"resolved": "git+ssh://git@github.com/awalsh128/ts-apt.git#4a0305c3fde1f42ea9a5c79ac53bf27fdf5d76c2",
"integrity": "sha512-9pjQ88xClPTKFMLXIh6l55cWPdrG88uzIjzS5MWo0eu3K/JUqVuZgCkClVq/v/i4jQgZqUSZFPW4oLnMwnP2xw==",
"resolved": "git+ssh://git@github.com/awalsh128/ts-apt.git#342c7d0f453dcf1a69da83d7d195f9df2611a351",
"integrity": "sha512-ae31APiufZiSrilTC3yCVV9smUD2tXzWUuFegTyG+h7UQGHmwfaw7ULzih5X1OSaQc3qtle4gQpHWhTNz4Y5kw==",
"license": "Apache-2.0",
"dependencies": {
"async-mutex": "^0.5.0",
@ -12338,9 +12536,9 @@
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
"integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
"license": "0BSD"
},
"node_modules/tsutils": {

View file

@ -47,7 +47,8 @@
"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",
"bundle": "rollup -c rollup.config.mjs",
"build": "npm run -s runall -- build:lib bundle build:scripts",
"clean": "rm -rf dist coverage",
"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",
@ -85,12 +86,15 @@
"class-transformer": "^0.5.1",
"rollup": "^4.62.2",
"tar": "^7.4.3",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#4a0305c3fde1f42ea9a5c79ac53bf27fdf5d76c2",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#342c7d0f453dcf1a69da83d7d195f9df2611a351",
"winston": "^3.19.0"
},
"devDependencies": {
"@devcontainers/cli": "^0.87.0",
"@robingenz/zli": "^0.2.0",
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",

65
rollup.config.mjs Normal file
View file

@ -0,0 +1,65 @@
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import fs from "node:fs";
// ts-apt runs scripts/dist/apt-fast.sh, which it finds relative to its own
// module (i.e. ../scripts/dist from the bundle). Bundling moves that module
// into dist/action/index.js, so ship the scripts at dist/scripts/dist.
const TS_APT_SCRIPTS_SRC = "node_modules/ts-apt/scripts/dist";
const TS_APT_SCRIPTS_DEST = "dist/scripts/dist";
/** Copies the ts-apt shell scripts next to the bundle after it's written. */
function copyTsAptScripts() {
return {
name: "copy-ts-apt-scripts",
writeBundle() {
fs.cpSync(TS_APT_SCRIPTS_SRC, TS_APT_SCRIPTS_DEST, { recursive: true });
},
};
}
/**
* Bundles the action and all of its dependencies into dist/action/index.js,
* the entrypoint action.yml runs. GitHub Actions doesn't install node_modules, so
* the bundle has to be self-contained.
*
* The input is the tsc output from `npm run build:lib`, so TypeScript is only
* compiled one way.
*
* @type {import('rollup').RollupOptions}
*/
export default {
input: "dist/src/index.js",
output: {
file: "dist/action/index.js",
format: "es",
sourcemap: true,
// Some CommonJS dependencies call require() at runtime.
banner:
"import { createRequire as __createRequire } from 'node:module';\n" +
"const require = __createRequire(import.meta.url);",
},
plugins: [
nodeResolve({ preferBuiltins: true, exportConditions: ["node"] }),
commonjs(),
json(),
copyTsAptScripts(),
],
onwarn(warning, warn) {
// Dependencies trigger these and they are harmless and very noisy:
// top level `this` in ES modules, misplaced /*@__PURE__*/ comments and
// circular imports inside third-party packages.
const fromDependency = (warning.ids ?? [warning.id ?? ""]).every((id) =>
id.includes("node_modules"),
);
if (
warning.code === "THIS_IS_UNDEFINED" ||
(fromDependency &&
["INVALID_ANNOTATION", "CIRCULAR_DEPENDENCY"].includes(warning.code))
) {
return;
}
warn(warning);
},
};

View file

@ -45,14 +45,31 @@ export class ActionRunner {
}
/**
* Converts manifest entries into the action output CSV format.
* Converts package names into the action output CSV format.
*
* @param manifest Parsed manifest.
* @returns Comma-delimited name=version list.
* Arch is shown only for foreign-arch packages (e.g. `libc6:i386=2.39`), so
* native output matches the v1 format while multi-arch installs stay
* unambiguous. Entries are sorted with `PackageName.compareTo` so the output
* is stable across install order.
*
* @param packageNames Installed package names.
* @param nativeArch Runner architecture as reported by dpkg (e.g. amd64).
* @returns Comma-delimited name[:arch]=version list.
*/
private toCsv(manifest: Manifest): string {
return manifest.entries
.map((entry) => entry.packageName.serialize())
private toCsv(
packageNames: readonly PackageName[],
nativeArch: string,
): string {
return [...packageNames]
.sort((a, b) => a.compareTo(b))
.map((name) => {
const foreignArch =
name.arch !== undefined && name.arch !== nativeArch
? `:${name.arch}`
: "";
const version = name.version === undefined ? "" : `=${name.version}`;
return `${name.name}${foreignArch}${version}`;
})
.join(",");
}
@ -77,12 +94,14 @@ export class ActionRunner {
);
const cacheHit = manifest !== undefined;
// Needed on a cache hit too, to resolve the native arch for outputs.
const installManager = await createPackageManager(
true,
this.logger,
this.logger,
);
if (!cacheHit) {
const installManager = await createPackageManager(
true,
this.logger,
this.logger,
);
const packageInfos = await installManager.install(packageNames.toArray());
// Collect each package's installed files so the archives have content.
const entries = await Promise.all(
@ -98,10 +117,15 @@ export class ActionRunner {
await this.cache.archiveAndSave(manifest);
}
const nativeArch = await installManager.getNativeArchitecture();
const installed = manifest!.entries.map((entry) => entry.packageName);
return {
cacheHit,
packageVersionList: this.toCsv(manifest!),
allPackageVersionList: this.toCsv(manifest!),
packageVersionList: this.toCsv(
installed.filter((name) => packageNames.includes(name)),
nativeArch,
),
allPackageVersionList: this.toCsv(installed, nativeArch),
};
}
}

View file

@ -1,6 +1,5 @@
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";
@ -12,6 +11,41 @@ import { CommandRunner, PackageName } from "ts-apt";
export const CACHE_DEFAULT_DIRNAME = "cache-apt-pkgs";
export const CACHE_KEY_FILENAME = "cache_key.md5";
/**
* Resolves the absolute cache directory path for a cache directory name.
*
* @param cacheDir Directory name under the user's home directory.
* @returns Absolute path to the cache directory.
*/
export function resolveCacheDir(
cacheDir: string = CACHE_DEFAULT_DIRNAME,
): string {
return path.join(os.homedir(), cacheDir);
}
/**
* Disables the command runner's default timeout for long-running commands.
*
* Archiving large packages (e.g. linux-headers) and some maintainer scripts
* (e.g. ca-certificates-java postinst) can take longer than the default.
*/
const NO_TIMEOUT_MS = -1;
/** Maintainer scripts run on restore when install scripts are enabled. */
type InstallScriptType = "preinst" | "postinst";
/** Order dpkg runs maintainer scripts in during install. */
const INSTALL_SCRIPT_ORDER: readonly InstallScriptType[] = [
"preinst",
"postinst",
];
/** Action argument dpkg passes to each maintainer script on a fresh install. */
const INSTALL_SCRIPT_ACTIONS: Readonly<Record<InstallScriptType, string>> = {
preinst: "install",
postinst: "configure",
};
export const MANIFEST_MAIN_FILENAME = "manifest_main.json";
export const MANIFEST_ALL_FILENAME = "manifest_all.json";
@ -106,38 +140,48 @@ export class Cache {
logger: winston.Logger,
cacheDir: string = CACHE_DEFAULT_DIRNAME,
) {
this.path = path.join(os.homedir(), cacheDir);
this.path = resolveCacheDir(cacheDir);
this.commandRunner = commandRunner;
this.logger = logger;
}
/**
* Runs a package's maintainer scripts in the order dpkg runs them on install.
*
* @param packageName Package whose scripts to run.
*/
private async runInstallScripts(packageName: PackageName): Promise<void> {
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}`);
};
for (const scriptType of INSTALL_SCRIPT_ORDER) {
await this.runInstallScript(packageName, scriptType);
}
}
await runScript(packageName, "preinst");
await runScript(packageName, "postinst");
/**
* Runs one maintainer script, if the package has it, with the action dpkg passes.
*
* @param packageName Package whose script to run.
* @param scriptType Maintainer script to run.
*/
private async runInstallScript(
packageName: PackageName,
scriptType: InstallScriptType,
): Promise<void> {
const scriptPath = await findInstallScript(packageName, scriptType, "/");
if (!scriptPath) {
this.logger.info(
`No ${scriptType} script found for package ${packageName.serialize()}, skipping`,
);
return;
}
this.logger.info(
`Running ${scriptType} script '${path.basename(scriptPath)}' for package ${packageName.serialize()}...`,
);
await this.commandRunner.run(
"sudo",
["sh", "-x", scriptPath, INSTALL_SCRIPT_ACTIONS[scriptType]],
{ timeoutMs: NO_TIMEOUT_MS },
);
this.logger.info(`Ran ${scriptType} script for ${packageName.name}`);
}
async loadAndRestore(
@ -215,18 +259,37 @@ export class Cache {
}
for (const { packageName, archivePath } of archivePaths) {
await tar.extract({
cwd: "/",
file: archivePath,
preservePaths: true,
});
await this.extractArchive(archivePath);
if (executeInstallScripts) {
await this.runInstallScripts(packageName);
}
}
return await Manifest.readFromFile(manifestPath);
// Return every installed package so outputs can report dependencies too.
return manifestAll;
}
/**
* Extracts a package archive onto the root filesystem.
*
* Runs system tar under sudo because package files live in root-owned
* directories. Fails on any entry error instead of skipping it.
*
* @param archivePath Absolute path to the package archive.
*/
private async extractArchive(archivePath: string): Promise<void> {
await this.commandRunner.run(
"sudo",
[
"tar",
"--extract",
`--file=${archivePath}`,
"--directory=/",
"--keep-directory-symlink",
],
{ timeoutMs: NO_TIMEOUT_MS },
);
}
private async createArchive(manifestEntry: ManifestEntry): Promise<void> {
@ -255,17 +318,29 @@ export class Cache {
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,
// Packages can install root-only files (e.g. /etc/default/cacerts from
// ca-certificates-java), so system tar runs under sudo. The file list is
// piped on stdin because large packages exceed the argument size limit,
// and is NUL-separated so no path is parsed as a tar option.
await this.commandRunner.run(
"sudo",
[
"tar",
"--create",
`--file=${archivePath}`,
"--directory=/",
"--null",
"--verbatim-files-from",
"--files-from=-",
],
{ input: manifestEntry.filepaths.join("\0"), timeoutMs: NO_TIMEOUT_MS },
);
// Hand the archive back to the runner user so the cache save can manage it.
await this.commandRunner.run("sudo", [
"chown",
`${os.userInfo().uid}:${os.userInfo().gid}`,
archivePath,
]);
this.logger.info(
`Archive created for package ${manifestEntry.packageName.serialize()} at ${archivePath}`,
);
@ -274,13 +349,10 @@ export class Cache {
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.serialize(), entry]),
// Keep the installed name, which carries the resolved version and arch.
const mainEntries = allEntries.filter((entry) =>
packages.includes(entry.packageName),
);
const mainEntries = packages.toArray().map((pkg) => {
const installed = entriesByName.get(pkg.serialize());
return new ManifestEntry(pkg, installed?.filepaths ?? []);
});
const write = async (manifest: Manifest, filename: string) => {
this.logger.info(

View file

@ -1,9 +1,35 @@
import * as core from "@actions/core";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runAction, type ActionInputs } from "./action.js";
import { DefaultCommandRunner } from "ts-apt";
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.js";
import { Instruments } from "./instrumentation.js";
/**
* Marks the bundled ts-apt apt-fast script executable when it's present.
*
* ts-apt spawns the script directly, and some ways of shipping the bundle
* (e.g. workflow artifacts) drop file modes.
*
* @param bundleDir Directory of the running bundle (i.e. dist/action).
*/
function ensureBundledScriptsExecutable(
bundleDir: string = path.dirname(fileURLToPath(import.meta.url)),
): void {
const aptFastPath = path.join(
bundleDir,
"..",
"scripts",
"dist",
"apt-fast.sh",
);
if (fs.existsSync(aptFastPath)) {
fs.chmodSync(aptFastPath, 0o755);
}
}
/**
* Parses empty package behavior input from workflow configuration.
*
@ -51,6 +77,7 @@ async function main(): Promise<void> {
const inputs = getInputs();
const instruments = new Instruments(cacheDir, inputs.debug || core.isDebug());
try {
ensureBundledScriptsExecutable();
const commandRunner = new DefaultCommandRunner(
instruments.execLogger,
instruments.execLogger,
@ -70,7 +97,11 @@ async function main(): Promise<void> {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
} finally {
instruments.artifacts.upload();
// Logs are diagnostics, so a failed upload shouldn't fail the action.
await instruments.artifacts.upload().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
core.warning(`Failed to upload diagnostic artifacts: ${message}`);
});
}
}

View file

@ -8,6 +8,7 @@ import {
CACHE_KEY_FILENAME,
MANIFEST_ALL_FILENAME,
MANIFEST_MAIN_FILENAME,
resolveCacheDir,
} from "./cache.js";
const APP_LOG_FILENAME = "capa_app.log";
@ -40,10 +41,26 @@ export class Artifacts {
) {}
/** Upload artifacts to GitHub Actions via [@actions/artifact]. */
upload(): string {
/**
* Artifact name, unique per action step in a workflow run.
*
* Artifact names must be unique within a run, and every job (or step) using
* the action uploads one, so include the attempt, job and step IDs.
*/
get name(): string {
const parts = [
this.runId,
process.env.GITHUB_RUN_ATTEMPT,
process.env.GITHUB_JOB,
process.env.GITHUB_ACTION,
].filter((part) => part !== undefined && part !== "");
return ["capa_artifacts", ...parts].join("-");
}
async upload(): Promise<string> {
const client = new DefaultArtifactClient();
client.uploadArtifact(
"capa_artifacts-" + this.runId,
await client.uploadArtifact(
this.name,
this.artifactFilenames
.map((filename) => path.join(this.dir, filename))
.filter((filepath) => fs.existsSync(filepath)),
@ -99,8 +116,10 @@ function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
/** Container for all instrumentation: telemetry, logging, and artifacts generated */
export class Instruments {
/** Cache directory where all artifacts are stored, including cached packages. */
/** Cache directory name where all artifacts are stored, including cached packages. */
readonly cacheDir: string;
/** Absolute path of the cache directory (i.e. under the home directory). */
readonly cacheDirPath: string;
/** Whether debug is enabled which affects artifacts otherwise not. */
readonly debug: boolean;
/** Manages artifact creation and upload. */
@ -112,11 +131,14 @@ export class Instruments {
constructor(cacheDir: string, debug: boolean, artifacts?: Artifacts) {
this.cacheDir = cacheDir;
this.cacheDirPath = resolveCacheDir(cacheDir);
this.debug = debug;
// The log streams open their files right away, so the directory must exist.
fs.mkdirSync(this.cacheDirPath, { recursive: true });
this.artifacts =
artifacts ??
new Artifacts(cacheDir, [
new Artifacts(this.cacheDirPath, [
APP_LOG_FILENAME,
EXEC_LOG_FILENAME,
CACHE_KEY_FILENAME,
@ -125,11 +147,11 @@ export class Instruments {
]);
this.appLogger = createGitHubLogger(
debug,
path.join(cacheDir, APP_LOG_FILENAME),
path.join(this.cacheDirPath, APP_LOG_FILENAME),
);
this.execLogger = createExecLogger(
debug,
path.join(cacheDir, EXEC_LOG_FILENAME),
path.join(this.cacheDirPath, EXEC_LOG_FILENAME),
);
}
}

View file

@ -171,6 +171,25 @@ export class ActionPackageNames {
toArray(): readonly PackageName[] {
return this.items;
}
/**
* Checks whether an installed package was one of the requested packages.
*
* Matches on name, and on arch only when both sides specify one. Version is
* ignored because requests usually omit it and apt resolves the installed one.
*
* @param installed Package name as reported by apt after install.
* @returns True when the package was requested.
*/
includes(installed: PackageName): boolean {
return this.items.some(
(requested) =>
requested.name === installed.name &&
(requested.arch === undefined ||
installed.arch === undefined ||
requested.arch === installed.arch),
);
}
}
/**

View file

@ -15,6 +15,8 @@ vi.mock("ts-apt", async (importOriginal) => {
};
});
const NATIVE_ARCH = "amd64";
describe("ActionRunner", () => {
afterEach(() => {
vi.restoreAllMocks();
@ -45,6 +47,9 @@ describe("ActionRunner", () => {
const cacheKey = new CacheKey("v1", "0", process.arch, packageNames);
const restored = new Manifest(new Date(), [], cacheKey);
cache.loadAndRestore.mockResolvedValue(restored);
vi.mocked(createPackageManager).mockResolvedValue({
getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH),
} as never);
const result = await runner.runAction({
packages: "curl git",
@ -68,6 +73,7 @@ describe("ActionRunner", () => {
vi.mocked(createPackageManager).mockResolvedValue({
install: vi.fn().mockResolvedValue([{ name: installedPackage }]),
listInstalledFiles: vi.fn().mockResolvedValue([installedFile]),
getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH),
} as never);
await runner.runAction({

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
runAction,
type ActionInputs,
@ -9,6 +9,19 @@ import { CacheKey } from "../src/cache.js";
import { createPackageName } from "ts-apt";
import { ActionPackageNames } from "../src/packages.js";
const NATIVE_ARCH = "amd64";
const FOREIGN_ARCH = "i386";
vi.mock("ts-apt", async (importOriginal) => {
const actual = await importOriginal<typeof import("ts-apt")>();
return {
...actual,
createPackageManager: async () => ({
getNativeArchitecture: async () => NATIVE_ARCH,
}),
};
});
describe("runAction", () => {
const PKG_NAME = "curl";
const PKG_VER = "1.2.3";
@ -71,4 +84,45 @@ describe("runAction", () => {
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
);
});
it("runAction cache hit with dependencies lists requested and all packages sorted with foreign arch only", async () => {
const inputs: ActionInputs = {
packages: `${PKG2_NAME} ${PKG_NAME}`,
version: PKG_VER,
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
};
const cacheKey = new CacheKey(
inputs.version,
"0",
process.arch,
ActionPackageNames.fromInput(inputs.packages),
);
// Install order, with a native-arch dependency and a foreign-arch request.
const manifest = new Manifest(
new Date(),
[
new ManifestEntry(createPackageName(PKG2_NAME, PKG_VER, FOREIGN_ARCH)),
new ManifestEntry(createPackageName(PKG3_NAME, PKG_VER, NATIVE_ARCH)),
new ManifestEntry(createPackageName(PKG_NAME, PKG_VER, undefined)),
],
cacheKey,
);
const mockCache = { loadAndRestore: async () => manifest };
const outputs = await runAction(
inputs,
{} as never,
mockCache as never,
{} as never,
);
expect(outputs.packageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER}`,
);
expect(outputs.allPackageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
);
});
});

View file

@ -1,8 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import * as ghcache from "@actions/cache";
import * as tar from "tar";
import { createPackageName, type PackageName } from "ts-apt";
import {
createPackageName,
type CommandOptions,
type PackageName,
} from "ts-apt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import winston from "winston";
@ -37,7 +40,6 @@ vi.mock("@actions/cache", () => ({
saveCache: vi.fn(),
isFeatureAvailable: vi.fn(),
}));
vi.mock("tar", () => ({ extract: vi.fn(), create: vi.fn() }));
const CACHE_DIR = path.join(HOME_DIR, CACHE_DEFAULT_DIRNAME);
const CACHE_ID = 42;
@ -81,9 +83,34 @@ async function writeCache(archivedPackages = INSTALL_ORDER): Promise<void> {
archivedPackages.forEach((pkg) => fs.writeFileSync(archivePath(pkg), ""));
}
function createCache(): Cache {
/** Archive operations observed through the command runner. */
interface TarCalls {
created: [archive: string, files: string[]][];
extracted: string[];
}
const argValue = (args: string[], flag: string) =>
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
/** Records sudo tar calls, including the file list piped to tar create. */
function recordTar(calls: TarCalls) {
return vi.fn(
async (_command: string, args: string[] = [], options: CommandOptions = {}) => {
const archive = argValue(args, "--file") ?? "";
if (args.includes("--create")) {
calls.created.push([archive, String(options.input ?? "").split("\0")]);
}
if (args.includes("--extract")) {
calls.extracted.push(archive);
}
return { cmdLine: "", stdout: "", stderr: "", exitCode: 0 };
},
);
}
function createCache(calls: TarCalls = { created: [], extracted: [] }): Cache {
const logger = winston.createLogger({ silent: true });
return new Cache({ run: vi.fn() } as never, logger);
return new Cache({ run: recordTar(calls) } as never, logger);
}
describe("Cache", () => {
@ -101,33 +128,34 @@ describe("Cache", () => {
it("loadAndRestore cache hit extracts archives in install order", async () => {
await writeCache();
const manifest = await createCache().loadAndRestore(KEY, false);
const calls: TarCalls = { created: [], extracted: [] };
const manifest = await createCache(calls).loadAndRestore(KEY, false);
expect(manifest).toBeDefined();
expect(
vi.mocked(tar.extract).mock.calls.map(([options]) => options?.file),
).toEqual(INSTALL_ORDER.map(archivePath));
expect(calls.extracted).toEqual(INSTALL_ORDER.map(archivePath));
});
it("loadAndRestore archive missing returns undefined without extracting", async () => {
await writeCache(INSTALL_ORDER.slice(1));
const manifest = await createCache().loadAndRestore(KEY, false);
const calls: TarCalls = { created: [], extracted: [] };
const manifest = await createCache(calls).loadAndRestore(KEY, false);
expect(manifest).toBeUndefined();
expect(tar.extract).not.toHaveBeenCalled();
expect(calls.extracted).toEqual([]);
});
it("archiveAndSave packages with files creates archives of their files", async () => {
const cacheId = await createCache().archiveAndSave(MANIFEST);
const calls: TarCalls = { created: [], extracted: [] };
const cacheId = await createCache(calls).archiveAndSave(MANIFEST);
expect(cacheId).toBe(CACHE_ID);
// tar.create is overloaded, so type the (options, files) call shape used.
const createCalls = vi.mocked(tar.create).mock.calls as unknown as [
{ file: string },
string[],
][];
expect(createCalls.map(([options, files]) => [options.file, files])).toEqual(INSTALL_ORDER.map((pkg) => [archivePath(pkg), filesOf(pkg)]));
expect(calls.created).toEqual(
INSTALL_ORDER.map((pkg) => [archivePath(pkg), filesOf(pkg)]),
);
});
it("archiveAndSave manifest written records package files", async () => {
@ -140,4 +168,37 @@ describe("Cache", () => {
MANIFEST.entries.map((entry) => entry.filepaths),
);
});
it("loadAndRestore cache hit returns all installed packages", async () => {
await writeCache();
const manifest = await createCache().loadAndRestore(KEY, false);
expect(
manifest?.entries.map((entry) => entry.packageName.serialize()),
).toEqual(MANIFEST.entries.map((entry) => entry.packageName.serialize()));
});
it("archiveAndSave requested package without version records installed entry in main manifest", async () => {
const unversionedKey = new CacheKey(
"v1",
"1",
"x86_64",
ActionPackageNames.fromInput(REQUESTED_PACKAGE.name),
);
await createCache().archiveAndSave(
new Manifest(MANIFEST.created, MANIFEST.entries, unversionedKey),
);
const written = await Manifest.readFromFile(
path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME),
);
expect(
written.entries.map((entry) => [
entry.packageName.serialize(),
entry.filepaths,
]),
).toEqual([[REQUESTED_PACKAGE.serialize(), filesOf(REQUESTED_PACKAGE)]]);
});
});

View file

@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Artifacts, Instruments } from "../src/instrumentation.ts";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -39,6 +42,41 @@ describe("instrumentation", () => {
const artifacts = new Artifacts(dir, ARTIFACT_FILENAMES, debug);
expect(artifacts.runId).toMatch(/^ghrunid-notfound-/);
});
it("name in a workflow step includes attempt, job and step", () => {
vi.stubEnv("GITHUB_RUN_ATTEMPT", "2");
vi.stubEnv("GITHUB_JOB", "list_versions");
vi.stubEnv("GITHUB_ACTION", "execute");
const artifacts = new Artifacts("/dir", ARTIFACT_FILENAMES, false, "123");
expect(artifacts.name).toBe(
"capa_artifacts-123-2-list_versions-execute",
);
});
});
describe("Instruments", () => {
const CACHE_DIRNAME = "cache-apt-pkgs";
it("constructor cache dir missing creates it under home with the logs", async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "instruments-test-"));
vi.stubEnv("HOME", home);
const expectedDir = path.join(home, CACHE_DIRNAME);
const instruments = new Instruments(CACHE_DIRNAME, false);
instruments.appLogger.info("app message");
await new Promise((resolve) => instruments.appLogger.end(resolve));
expect(instruments.cacheDirPath).toBe(expectedDir);
expect(instruments.artifacts.dir).toBe(expectedDir);
await vi.waitFor(() =>
expect(
fs.readFileSync(path.join(expectedDir, "capa_app.log"), "utf8"),
).toContain("app message"),
);
fs.rmSync(home, { recursive: true, force: true });
});
});
// describe("Instruments", () => {

View file

@ -170,5 +170,18 @@ describe("packages", () => {
createPackageName("test-package", "1.0.0", "amd64"),
]);
});
it.each([
["same name without arch returns true", "xdot", createPackageName("xdot", "1.3-1"), true],
["requested without arch returns true", "xdot", createPackageName("xdot", "1.3-1", "amd64"), true],
["installed without arch returns true", "xdot:amd64", createPackageName("xdot", "1.3-1"), true],
["matching arch returns true", "xdot:amd64", createPackageName("xdot", "1.3-1", "amd64"), true],
["different arch returns false", "xdot:i386", createPackageName("xdot", "1.3-1", "amd64"), false],
["different name returns false", "xdot", createPackageName("rolldice", "1.16"), false],
])("includes %s", (_case, input, installed, expected) => {
expect(ActionPackageNames.fromInput(input).includes(installed)).toBe(
expected,
);
});
});
});