- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
196 lines
10 KiB
JavaScript
196 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// A release is where this repository's committed binary stops being an
|
|
// internal claim and becomes something other people install. These tests guard
|
|
// the parts of that path which fail quietly: an elevated permission that leaks
|
|
// out of the one job meant to hold it, an action pinned to a moving tag, a
|
|
// publication that never re-checked the bytes it publishes, or a release whose
|
|
// version agrees with nothing.
|
|
//
|
|
// They do not run a release. What can be checked here is that the definition
|
|
// grants the least it can, verifies before it publishes, and says out loud
|
|
// what its artifacts are and are not.
|
|
//
|
|
// node tests/release-provenance.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
|
|
const repoRoot = path.join(__dirname, "..")
|
|
const read = p => fs.readFileSync(path.join(repoRoot, p), "utf8")
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
|
|
|
const release = read(".github/workflows/release.yml")
|
|
const build = read(".github/workflows/agent-build.yml")
|
|
const owners = read(".github/CODEOWNERS")
|
|
|
|
// Split the file into its jobs, so a question like "which job can write" has
|
|
// a per-job answer rather than a whole-file one. Grepping the whole workflow
|
|
// for `id-token: write` would pass just as happily if every job had it.
|
|
const jobsBody = release.slice(release.indexOf("\njobs:"))
|
|
const jobs = new Map(
|
|
[...jobsBody.matchAll(/^ {2}([a-z][a-z0-9-]*):\n([\s\S]*?)(?=^ {2}[a-z][a-z0-9-]*:\n|$(?![\s\S]))/gm)]
|
|
.map(([, name, body]) => [name, body]))
|
|
|
|
check("the workflow defines the three stages it describes",
|
|
["gates", "verify", "release"].every(j => jobs.has(j)),
|
|
`jobs found: ${[...jobs.keys()].join(", ")}`)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// What triggers it, and what cannot
|
|
// -------------------------------------------------------------------------
|
|
|
|
check("a release is a tag, not a branch push",
|
|
/on:\n(?:.*\n)*?\s*push:\n\s*tags:\n\s*- 'v\*'/.test(release) && !/^\s*branches:/m.test(release),
|
|
"the release workflow runs on something other than a version tag")
|
|
check("a dispatch run can rehearse the whole path",
|
|
/workflow_dispatch:/.test(release),
|
|
"verification cannot be run without creating a tag")
|
|
check("only a tag reaches the publishing job",
|
|
/if: github\.ref_type == 'tag'/.test(jobs.get("release") || ""),
|
|
"a dispatch run could publish a release or sign an attestation")
|
|
check("a release in flight is never cancelled",
|
|
/concurrency:[\s\S]{0,200}?cancel-in-progress: false/.test(release),
|
|
"a second tag could cancel a half-published release")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Least privilege, and where it stops
|
|
// -------------------------------------------------------------------------
|
|
|
|
const topLevel = release.slice(0, release.indexOf("\njobs:"))
|
|
check("the workflow is read-only by default",
|
|
/^permissions:\n\s*contents: read\s*$/m.test(topLevel),
|
|
"the default token carries more than read")
|
|
|
|
const elevated = ["contents: write", "id-token: write", "attestations: write"]
|
|
for (const grant of elevated) {
|
|
const holders = [...jobs].filter(([, body]) => body.includes(grant)).map(([name]) => name)
|
|
check(`only the release job holds ${grant}`,
|
|
holders.length === 1 && holders[0] === "release",
|
|
`held by: ${holders.join(", ") || "nobody"}`)
|
|
}
|
|
check("the gates and verify jobs state their read-only scope rather than inheriting it",
|
|
/permissions:\n\s*contents: read/.test(jobs.get("gates") || "")
|
|
&& /permissions:\n\s*contents: read/.test(jobs.get("verify") || ""),
|
|
"a later change to the default would silently widen these jobs")
|
|
check("the elevated job runs behind an environment",
|
|
/environment:\n\s*name: release/.test(jobs.get("release") || ""),
|
|
"elevated credentials come into existence with no approval step")
|
|
check("no secret is referenced",
|
|
!/secrets\./.test(release),
|
|
"a release should need nothing beyond the job's own token")
|
|
|
|
// A tag is a moving pointer, and this is the workflow that mints signing
|
|
// credentials. Same argument as pinning the build image by digest, applied to
|
|
// the code that runs the release.
|
|
const actionUses = [...release.matchAll(/uses:\s*([^\s@]+)@(\S+)/g)]
|
|
.filter(([, name]) => !name.startsWith("./"))
|
|
check("every third-party action is pinned to a full commit SHA",
|
|
actionUses.length > 0 && actionUses.every(([, , ref]) => /^[0-9a-f]{40}$/.test(ref)),
|
|
actionUses.filter(([, , ref]) => !/^[0-9a-f]{40}$/.test(ref)).map(m => m[0]).join(", ") || "no actions used")
|
|
check("each pin says which release it is, for a human",
|
|
(release.match(/@[0-9a-f]{40} # v\d/g) || []).length === actionUses.length,
|
|
"a bare SHA tells a reviewer nothing about what version it is")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The gates are the branch's gates
|
|
// -------------------------------------------------------------------------
|
|
|
|
check("the release calls the branch's gates instead of copying them",
|
|
/uses: \.\/\.github\/workflows\/agent-build\.yml/.test(jobs.get("gates") || ""),
|
|
"the release re-implements the checks, so the two can drift apart")
|
|
check("the branch workflow is callable",
|
|
/^\s*workflow_call:/m.test(build),
|
|
"release.yml calls a workflow that does not accept being called")
|
|
// The gates run as part of the release, so they share the called workflow's
|
|
// concurrency group. Scoped by ref alone, a branch build and a release could
|
|
// cancel each other -- which is exactly what release.yml's own
|
|
// `cancel-in-progress: false` exists to prevent.
|
|
check("a branch build and a release cannot cancel each other",
|
|
/group: agent-build-\$\{\{ github\.workflow \}\}/.test(build)
|
|
&& /cancel-in-progress: \$\{\{ github\.workflow != 'release' \}\}/.test(build),
|
|
"the called workflow's concurrency group does not distinguish its callers")
|
|
check("nothing publishes before those gates pass",
|
|
/needs: gates/.test(jobs.get("verify") || "") && /needs: verify/.test(jobs.get("release") || ""),
|
|
"the publishing job does not depend on verification")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// What the release verifies about itself
|
|
// -------------------------------------------------------------------------
|
|
|
|
const verify = jobs.get("verify") || ""
|
|
check("the tag, the manifest and the changelog must agree",
|
|
/manifest\.json says/.test(verify) && /CHANGELOG\.md has no/.test(verify),
|
|
"a tag could name a version nothing else in the repository claims")
|
|
check("the committed checksum is re-checked against the committed bytes",
|
|
(release.match(/sha256sum -c SHA256SUMS/g) || []).length >= 2,
|
|
"the binary and the checksum shipped beside it are never compared at release time")
|
|
check("the helper's reported versions are checked against the panel's",
|
|
/SSH_AGENT_CONTROL_VERSION/.test(verify) && /control protocol/.test(verify),
|
|
"a protocol bump could ship to users and disable the feature at launch")
|
|
check("the shipped binary is executed, not merely compiled",
|
|
/--self-test/.test(verify), "nothing runs the bytes being released")
|
|
// A container job's default shell is the image's `sh`. The scan step uses an
|
|
// array, and sh has none: the first rehearsal of this workflow died on
|
|
// `Syntax error: "(" unexpected` after every expensive step had already
|
|
// passed.
|
|
check("the container job declares bash rather than taking the image's sh",
|
|
/defaults:\n\s*run:\n(?:\s*#[^\n]*\n)*\s*shell: bash/.test(verify),
|
|
"a bashism in a container step fails at the end of a long job")
|
|
|
|
const releaseJob = jobs.get("release") || ""
|
|
check("the release job runs the bytes outside the build container",
|
|
!/container:/.test(releaseJob) && /--self-test/.test(releaseJob),
|
|
"a binary that only works inside its own build image would still be published")
|
|
check("the attestation names the shipped binary as its subject",
|
|
/attest-build-provenance@[0-9a-f]{40}[\s\S]{0,300}?subject-path: bin\/x86_64-linux\/qs-bitwarden-ssh-agent/
|
|
.test(releaseJob),
|
|
"the provenance attestation does not bind the tracked bytes")
|
|
check("the verification command is written down where a reviewer will find it",
|
|
/gh attestation verify/.test(release),
|
|
"users are given provenance with no documented way to check it")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// What the release publishes
|
|
// -------------------------------------------------------------------------
|
|
|
|
check("an SBOM is published", /cyclonedx/.test(verify), "no SBOM is produced")
|
|
check("a dependency and licence report is published",
|
|
/cargo deny[\s\S]{0,140}?\blist\b/.test(verify) && /cargo tree/.test(verify),
|
|
"users cannot see what is in the binary or under what terms")
|
|
check("debug symbols are published separately from the shipped bytes",
|
|
/only-keep-debug/.test(verify) && /\.debug/.test(verify),
|
|
"a stripped binary ships with no way to debug it at all")
|
|
// The release profile strips symbols, and a debug build links differently --
|
|
// its entry point and code layout move. Publishing the file is fine.
|
|
// Implying it maps onto the shipped bytes would not be.
|
|
check("the debug symbols say plainly that they are not the shipped bytes",
|
|
/DEBUG-SYMBOLS\.md/.test(verify) && /not[\s\S]{0,80}split of the shipped binary/i.test(verify),
|
|
"the symbol file invites address-level conclusions it cannot support")
|
|
check("release artifacts are scanned for key material before publication",
|
|
/PRIVATE KEY/.test(verify) && /BW_SESSION/.test(verify),
|
|
"nothing checks that a report or symbol file is free of secrets")
|
|
check("the release notes are the changelog section for this version",
|
|
/release-notes\.md/.test(releaseJob) && /no changelog section for/.test(releaseJob),
|
|
"a release could publish empty notes or the entire changelog")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Who has to look at it
|
|
// -------------------------------------------------------------------------
|
|
|
|
check("the release workflow requires code-owner review",
|
|
/release\.yml/.test(owners) || /^\/\.github\/\s+@/m.test(owners),
|
|
"the one workflow that can publish is not owned by anyone")
|
|
check("the binary, its build script and the agent source stay owned",
|
|
/^\/bin\/\s+@/m.test(owners) && /build-agent\.sh\s+@/m.test(owners) && /^\/agent\/\s+@/m.test(owners),
|
|
"a change to the trust path could merge without review")
|
|
|
|
if (failures.length) {
|
|
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
|
failures.forEach(f => console.error(` FAIL ${f}`))
|
|
process.exit(1)
|
|
}
|
|
console.log(`release-provenance: ${pass} passed`)
|