Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ jobs:
run: |
git diff --exit-code -- dist
test -z "$(git ls-files --others --exclude-standard dist)"
- run: pnpm audit --prod --audit-level moderate
- name: Validate production SBOM
run: |
pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod --out "$RUNNER_TEMP/setup-zig.cdx.json"
jq -e '.bomFormat == "CycloneDX" and (.components | length >= 7)' "$RUNNER_TEMP/setup-zig.cdx.json"
- run: pnpm audit --audit-level moderate

smoke:
needs: check
Expand Down
59 changes: 54 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,39 +24,88 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Validate version
- name: Validate version and release settings
env:
VERSION: ${{ inputs.version }}
REPOSITORY_PRIVATE: ${{ github.event.repository.private }}
GH_TOKEN: ${{ github.token }}
run: |
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
test "v$(jq -r '.version' package.json)" = "$VERSION"
if [[ "$REPOSITORY_PRIVATE" != false ]]; then
echo 'Make the repository public before publishing attestations.' >&2
exit 1
fi
if ! gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$GITHUB_REPOSITORY/immutable-releases" --silent; then
echo 'Enable immutable releases in repository settings first.' >&2
exit 1
fi
if gh release view "$VERSION" >/dev/null 2>&1; then
echo 'This release already exists.' >&2
exit 1
fi

test:
needs: validate
uses: ./.github/workflows/ci.yml
with:
full: true

publish:
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 5
timeout-minutes: 10
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Publish release and update the major tag
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3
with:
cache: true
require-lockfile: true
- name: Prepare release assets
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
mkdir -p release
git archive --format=tar --prefix=setup-zig/ "$GITHUB_SHA" |
gzip -n > "release/setup-zig-$VERSION.tar.gz"
pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod --out release/sbom.cdx.json
jq -e '.bomFormat == "CycloneDX" and (.components | length >= 7)' release/sbom.cdx.json
(
cd release
sha256sum "setup-zig-$VERSION.tar.gz" sbom.cdx.json > SHA256SUMS
)
- name: Attest release archive
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release/setup-zig-*.tar.gz
- name: Attest production dependencies
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release/setup-zig-*.tar.gz
sbom-path: release/sbom.cdx.json
- name: Publish immutable release and update the major tag
env:
VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ github.token }}
run: |
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA"
gh release create "$VERSION" --verify-tag --generate-notes --title "$VERSION"
tag_ref="repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION"
if gh api "$tag_ref" >/dev/null 2>&1; then
test "$(gh api "$tag_ref" --jq '.object.sha')" = "$GITHUB_SHA" || {
echo 'The release tag already points to another commit.' >&2
exit 1
}
else
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA"
fi
gh release create "$VERSION" release/* --verify-tag --generate-notes --title "$VERSION"
major="${VERSION%%.*}"
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$major" >/dev/null 2>&1; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" -f sha="$GITHUB_SHA" -F force=true
Expand Down
8 changes: 4 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ Keep the input surface small. Add a regression test for changes to version resol

## Releases

1. Update `package.json` and `pnpm-lock.yaml` to the release version. Rebuild `dist/` and merge the change.
2. Run the **Release** workflow from `main`, with a tag such as `v1.0.0`.
3. The workflow runs the full test matrix, creates the version tag and GitHub release, then moves the corresponding major tag (for example, `v1`). Existing version tags are never replaced.
1. Update the version in `package.json`. Refresh the lockfile only if dependencies change; rebuild `dist/` and merge.
2. Make the repository public, enable immutable releases and private vulnerability reporting, then run the **Release** workflow from `main` with a tag such as `v1.0.0`.
3. The workflow runs the full test matrix, generates a release archive, CycloneDX SBOM, checksums, and signed attestations, then publishes the immutable version tag and release. The matching major tag (for example, `v1`) advances to the new version.

Before the first public release, make the repository public and enable private vulnerability reporting in repository settings. Publish the action to GitHub Marketplace from the release page if desired. Release tags identify exact revisions; major tags follow compatible updates.
Verify the release and its assets as described in [Security](SECURITY.md#release-provenance). Publish the action to GitHub Marketplace from the release page if desired. Version tags are immutable; major tags follow compatible updates.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,6 @@ Linux, macOS, and Windows are supported on x64 and arm64. Linux and Windows also

The action uses Node 24 supplied by the Actions runner. Self-hosted runners need version 2.327.1 or later. Linux and macOS require `tar` with xz support; Windows requires PowerShell. No Node installation step or GitHub token input is needed.

Release archives have signed provenance, a CycloneDX SBOM, and immutable release attestations. See [release verification](SECURITY.md#release-provenance).

[Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [License](LICENSE)
19 changes: 19 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ Signed archives are cached rather than extracted executables. Every restored arc

The Zig release key and the action revision are trust anchors. Version metadata is fetched over HTTPS and stored within GitHub's cache scope. Pin an exact Zig version to avoid mutable version selection, and pin the action to a full commit SHA.

## Release provenance

Versioned releases are immutable. GitHub attests the release tag, source commit, and uploaded assets. The release workflow also generates signed SLSA build provenance and a CycloneDX SBOM attestation for the attached archive. The SBOM covers production npm dependencies bundled in the action, not Zig itself or development tools.

Verify a release and its downloaded archive with the GitHub CLI:

```sh
gh release verify v1.0.0 -R ekkolon/setup-zig
gh release download v1.0.0 -R ekkolon/setup-zig --pattern 'setup-zig-*.tar.gz'
gh release verify-asset v1.0.0 setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig
gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig \
--signer-workflow ekkolon/setup-zig/.github/workflows/release.yml
gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig \
--signer-workflow ekkolon/setup-zig/.github/workflows/release.yml \
--predicate-type https://cyclonedx.org/bom
```

The release also provides `sbom.cdx.json` and `SHA256SUMS`. The `v1` major tag intentionally moves to compatible releases; pin a full action commit SHA or an immutable version tag when evaluating a fixed dependency. Attestations identify the publishing workflow and artifacts, but do not certify that the code is vulnerability-free or compliant with a particular standard.

## Runner and cache trust

GitHub's normal cache access rules apply. Build caches contain unsigned compiler output and packages, so treat them as workflow data. Use `cache: false` for jobs that must rebuild without restored build data. `cache-read-only` prevents this action from uploading caches; it does not make restored data trusted.
Expand Down
15 changes: 9 additions & 6 deletions dist/main.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface InstallOptions {
release: Release;
target: Platform;
cache: CacheStore;
onWarning?: (message: string) => void;
/** Called only after both archive caches miss. */
getMirrors: () => Promise<string[]>;
}
Expand Down Expand Up @@ -141,6 +142,7 @@ async function restoreActionsArchive(
directory: string,
release: Release,
cache: CacheStore,
warn: (message: string) => void,
): Promise<VerifiedArchive | undefined> {
for (const filename of release.filenames) {
const archive = path.join(directory, filename);
Expand All @@ -157,9 +159,7 @@ async function restoreActionsArchive(
const sha256 = await verifyArchive(archive, signature, filename, release);
return {filename, sha256};
} catch (error) {
core.warning(
`Ignoring invalid Zig archive cache: ${getErrorMessage(error)}`,
);
warn(`Ignoring invalid Zig archive cache: ${getErrorMessage(error)}`);
await resetDirectory(directory);
}
}
Expand Down Expand Up @@ -235,7 +235,12 @@ export async function installZig(
}
}
if (!verified) {
verified = await restoreActionsArchive(directory, release, options.cache);
verified = await restoreActionsArchive(
directory,
release,
options.cache,
options.onWarning ?? core.warning,
);
if (verified) {
source = 'actions-cache';
}
Expand Down
3 changes: 2 additions & 1 deletion src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface MetadataOptions<T> {
cache: CacheStore;
parse: (body: string) => T;
forceRefresh?: boolean;
onWarning?: (message: string) => void;
/** Maximum age allowed after a failed refresh; disabled when omitted. */
maxStaleAgeMs?: number;
now?: number;
Expand Down Expand Up @@ -121,7 +122,7 @@ export async function readCachedMetadata<T>(
options.maxStaleAgeMs &&
now - saved.fetchedAt < options.maxStaleAgeMs
) {
core.warning(
(options.onWarning ?? core.warning)(
`Could not refresh ${options.name}; using the cached list: ${getErrorMessage(error)}`,
);
return options.parse(saved.body);
Expand Down
13 changes: 13 additions & 0 deletions test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ test('a remote metadata hit avoids contacting ziglang.org', async (context) => {
test('expired version metadata fails closed; mirrors have a bounded stale fallback', async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'setup-zig-stale-'));
context.after(() => rm(root, {recursive: true, force: true}));
const warnings: string[] = [];
const options = {
root,
cache: memoryCache().store,
Expand All @@ -140,6 +141,7 @@ test('expired version metadata fails closed; mirrors have a bounded stale fallba
ttlMs: 1000,
now: 5000,
parse: JSON.parse,
onWarning: (message: string) => warnings.push(message),
fetchText: async () => {
throw new Error('HTTP 429');
},
Expand All @@ -158,6 +160,9 @@ test('expired version metadata fails closed; mirrors have a bounded stale fallba
readCachedMetadata({...options, maxStaleAgeMs: 2000}),
/HTTP 429/,
);
assert.deepEqual(warnings, [
'Could not refresh index; using the cached list: HTTP 429',
]);
});

test('malformed, future-dated, and wrong-origin metadata cannot suppress a refresh', async (context) => {
Expand Down Expand Up @@ -356,6 +361,7 @@ for (const cacheResult of ['partial', 'invalid']) {
});
let archiveDirectory = '';
let saved = false;
const warnings: string[] = [];
const cache: CacheStore = {
restore: async (paths, key) => {
const archive = paths[0] ?? '';
Expand All @@ -377,6 +383,7 @@ for (const cacheResult of ['partial', 'invalid']) {
},
target: {os: 'linux', arch: 'x86_64'},
cache,
onWarning: (message: string) => warnings.push(message),
getMirrors: async () => {
assert.deepEqual(await readdir(archiveDirectory), []);
throw new Error('download boundary reached');
Expand All @@ -385,5 +392,11 @@ for (const cacheResult of ['partial', 'invalid']) {
/download boundary reached/,
);
assert.equal(saved, false);
assert.deepEqual(
warnings,
cacheResult === 'invalid'
? ['Ignoring invalid Zig archive cache: Malformed minisign signature.']
: [],
);
});
}
35 changes: 35 additions & 0 deletions test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,38 @@ test('workflow actions are pinned and untrusted code does not receive write perm
}
}
});

test('releases require immutability and attest their archive and SBOM', async () => {
const workflow = parse(
await readFile('.github/workflows/release.yml', 'utf8'),
);
const validate = workflow.jobs.validate.steps.find(
(step: {name?: string}) =>
step.name === 'Validate version and release settings',
);
assert.match(validate.run, /immutable-releases/);
assert.match(validate.run, /REPOSITORY_PRIVATE/);

const publish = workflow.jobs.publish;
assert.equal(publish.permissions['id-token'], 'write');
assert.equal(publish.permissions.attestations, 'write');
assert.equal(publish.permissions['artifact-metadata'], 'write');

const packageStep = publish.steps.find(
(step: {name?: string}) => step.name === 'Prepare release assets',
);
assert.match(packageStep.run, /git archive/);
assert.match(packageStep.run, /pnpm sbom[^\n]+--prod/);
assert.match(packageStep.run, /sha256sum/);

const attestations = publish.steps.filter((step: {uses?: string}) =>
step.uses?.startsWith('actions/attest@'),
);
assert.equal(attestations.length, 2);
assert.equal(
attestations[0].with['subject-path'],
'release/setup-zig-*.tar.gz',
);
assert.equal(attestations[1].with['sbom-path'], 'release/sbom.cdx.json');
assert.match(publish.steps.at(-1).run, /gh release create[^\n]+release\/\*/);
});
Loading