Skip to content
Open
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
65 changes: 59 additions & 6 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ jobs:
has_signing_secrets: ${{ steps.signing.outputs.has_signing_secrets }}

steps:
- name: Require main branch for stable publication
if: >-
${{ inputs.publish == true
&& inputs.release_channel == 'stable'
&& github.ref != 'refs/heads/main' }}
run: |
echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down Expand Up @@ -70,6 +79,7 @@ jobs:

- name: Validate macOS signing secrets
id: signing
if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }}
env:
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
Expand Down Expand Up @@ -112,12 +122,12 @@ jobs:

- name: Package arm64 desktop artifacts
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
CSC_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_P12 || '' }}
CSC_KEY_PASSWORD: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_PASSWORD || '' }}
CSC_NAME: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_NAME || '' }}
APPLE_ID: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_APP_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_TEAM_ID || '' }}
run: pnpm exec turbo run desktop:build --filter=@bb/desktop --force --output-logs=new-only

- name: Smoke test packaged desktop app
Expand Down Expand Up @@ -147,6 +157,15 @@ jobs:
timeout-minutes: 45

steps:
- name: Require main branch for stable publication
if: >-
${{ inputs.publish == true
&& inputs.release_channel == 'stable'
&& github.ref != 'refs/heads/main' }}
run: |
echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down Expand Up @@ -203,12 +222,24 @@ jobs:
needs:
- macos
- linux
if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
permissions:
contents: write
id-token: write
attestations: write

steps:
- name: Require main branch for stable publication
if: >-
${{ inputs.publish == true
&& inputs.release_channel == 'stable'
&& github.ref != 'refs/heads/main' }}
run: |
echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down Expand Up @@ -264,6 +295,24 @@ jobs:
name: bb-desktop-linux-x64
path: release/linux

- name: Attest Linux AppImage provenance
if: steps.release_plan.outputs.should_publish == 'true'
id: attest_linux
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: release/linux/*.AppImage

- name: Attest macOS desktop provenance
if: >-
${{ steps.release_plan.outputs.should_publish == 'true'
&& steps.release_plan.outputs.publish_macos_binaries == 'true' }}
id: attest_macos
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: |
release/macos/*.dmg
release/macos/*.zip

- name: Publish stable desktop release feed
if: steps.release_plan.outputs.should_publish == 'true'
env:
Expand Down Expand Up @@ -360,6 +409,8 @@ jobs:

- name: Summarize
env:
ATTESTATION_LINUX_URL: ${{ steps.attest_linux.outputs.attestation-url }}
ATTESTATION_MACOS_URL: ${{ steps.attest_macos.outputs.attestation-url }}
IS_PRERELEASE: ${{ steps.release_plan.outputs.is_prerelease }}
PUBLISH_MACOS_BINARIES: ${{ steps.release_plan.outputs.publish_macos_binaries }}
SHOULD_PUBLISH: ${{ steps.release_plan.outputs.should_publish }}
Expand All @@ -376,4 +427,6 @@ jobs:
echo "- macOS release binary upload enabled: ${PUBLISH_MACOS_BINARIES}"
echo "- macOS feed URL: https://github.com/get-bb/bb/releases/download/desktop-latest/desktop-version.json"
echo "- Linux feed URL: https://github.com/get-bb/bb/releases/download/desktop-latest/desktop-version-linux.json"
echo "- Linux binary provenance attestation: ${ATTESTATION_LINUX_URL}"
echo "- macOS binary provenance attestation: ${ATTESTATION_MACOS_URL:-not published}"
} >> "$GITHUB_STEP_SUMMARY"
209 changes: 209 additions & 0 deletions .github/workflows/check-release-hardening.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));

function readRepoFile(relativePath) {
return readFileSync(resolve(repoRoot, relativePath), "utf8");
}

const failures = [];

function assert(condition, message) {
if (!condition) {
failures.push(message);
}
}

function jobSection(workflow, jobName) {
const start = workflow.indexOf(`\n ${jobName}:\n`);
if (start === -1) {
return "";
}

const rest = workflow.slice(start + 1);
const nextJob = rest.search(/\n [A-Za-z0-9_-]+:\n/u);
return nextJob === -1 ? rest : rest.slice(0, nextJob);
}

function assertGateBeforeCheckout(
workflow,
jobName,
gateName,
message,
condition,
) {
const section = jobSection(workflow, jobName);
const gate = section.indexOf(`- name: ${gateName}`);
const checkout = section.indexOf("- name: Checkout repository");

assert(section.length > 0, `${message}: job is missing`);
assert(
gate !== -1 && gate < checkout,
`${message}: gate must precede checkout`,
);
if (condition) {
assert(section.includes(condition), `${message}: missing ${condition}`);
}
}

const buildDesktop = readRepoFile(".github/workflows/build-desktop.yml");
for (const jobName of ["macos", "linux", "publish"]) {
assertGateBeforeCheckout(
buildDesktop,
jobName,
"Require main branch for stable publication",
`build-desktop/${jobName}`,
"inputs.publish == true",
);
}
assert(
jobSection(buildDesktop, "publish").includes(
"if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }}",
),
"build-desktop/publish: QA runs must not receive publication permissions",
);
assert(
buildDesktop.includes(
"CSC_LINK: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_P12 || '' }}",
),
"build-desktop: signing secrets must be withheld from QA packaging",
);
assert(
buildDesktop.includes(
"uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0",
),
"build-desktop: stable binaries need a pinned provenance action",
);
assert(
jobSection(buildDesktop, "publish").includes("attestations: write"),
"build-desktop/publish: artifact-attestation permission is required",
);
const stableDesktopPublish = jobSection(buildDesktop, "publish");
assert(
stableDesktopPublish.includes("id: attest_linux") &&
stableDesktopPublish.includes("subject-path: release/linux/*.AppImage"),
"build-desktop/publish: Linux AppImage attestation must cover published assets",
);
assert(
stableDesktopPublish.includes("id: attest_macos") &&
stableDesktopPublish.includes(
"steps.release_plan.outputs.publish_macos_binaries == 'true'",
),
"build-desktop/publish: macOS attestation must be conditional on published assets",
);

for (const workflowName of [
"deploy-connect.yml",
"deploy-demo-server.yml",
"deploy-web.yml",
]) {
const workflow = readRepoFile(`.github/workflows/${workflowName}`);
assertGateBeforeCheckout(
workflow,
"deploy",
"Require main branch",
workflowName,
"github.ref != 'refs/heads/main'",
);
}

const mobileEas = readRepoFile(".github/workflows/mobile-ios-eas.yml");
assertGateBeforeCheckout(
mobileEas,
"build",
"Require main branch for TestFlight submission",
"mobile-ios-eas/build",
"inputs.submit == true",
);

const publish = readRepoFile(".github/workflows/publish-bb-app.yml");
for (const jobName of ["publish", "publish-nightly", "publish-plugin-sdk"]) {
assertGateBeforeCheckout(
publish,
jobName,
"Require main branch",
`publish-bb-app/${jobName}`,
"github.ref != 'refs/heads/main'",
);
}
for (const jobName of [
"nightly-desktop-macos",
"nightly-desktop-linux",
"nightly-desktop-publish",
]) {
assertGateBeforeCheckout(
publish,
jobName,
jobName === "nightly-desktop-publish"
? "Require main branch for manual nightly publication"
: "Require main branch for manual nightly release",
`publish-bb-app/${jobName}`,
"github.event_name == 'workflow_dispatch'",
);
}
assert(
!publish.includes("npm@latest"),
"publish-bb-app: npm@latest is forbidden",
);
assert(
publish.match(/npm install --global npm@11\.6\.2/g)?.length === 3,
"publish-bb-app: all three npm jobs must install npm 11.6.2",
);
assert(
publish.includes(
"uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0",
),
"publish-bb-app: nightly binaries need a pinned provenance action",
);
assert(
jobSection(publish, "nightly-desktop-publish").includes(
"attestations: write",
),
"publish-bb-app/nightly-desktop-publish: artifact-attestation permission is required",
);

for (const workflowName of ["mobile-e2e.yml", "mobile-runner-probe.yml"]) {
assert(
!readRepoFile(`.github/workflows/${workflowName}`).includes(
"Require main branch",
),
`${workflowName}: QA workflow must remain branch-flexible`,
);
}

const rootPackage = JSON.parse(readRepoFile("package.json"));
const overrides = rootPackage.pnpm?.overrides ?? {};
assert(
overrides["@ungap/structured-clone"] === "1.3.4",
"package.json: @ungap/structured-clone must be overridden to 1.3.4",
);
assert(
overrides["@xmldom/xmldom@0.8.13"] === "0.8.15",
"package.json: @xmldom/xmldom 0.8.x must be overridden to 0.8.15",
);
assert(
overrides["@xmldom/xmldom@0.9.10"] === "0.9.12",
"package.json: @xmldom/xmldom 0.9.x must be overridden to 0.9.12",
);

const lockfile = readRepoFile("pnpm-lock.yaml");
const resolvedPackages = lockfile.slice(lockfile.indexOf("\npackages:"));
for (const forbidden of [
"@ungap/structured-clone@1.3.0",
"@xmldom/xmldom@0.8.13",
"@xmldom/xmldom@0.9.10",
"Potential CWE-502 - Update to 1.3.1 or higher",
"this version has critical issues, please update to the latest version",
]) {
const source = forbidden.includes("CWE") ? lockfile : resolvedPackages;
assert(!source.includes(forbidden), `pnpm-lock.yaml: stale ${forbidden}`);
}

if (failures.length > 0) {
console.error(failures.map((failure) => `- ${failure}`).join("\n"));
process.exit(1);
}

console.log("Release workflow and dependency hardening checks passed.");
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
cache-prefix: checks

- name: Check release workflow hardening
run: node .github/workflows/check-release-hardening.mjs

# Four tasks for four vCPUs: TypeScript 7 and esbuild are multi-threaded,
# so turbo's default of ten concurrent tasks only adds contention here.
# Measured pinned to 4 CPUs, two rounds each: 96 s / 110 s at the
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-connect.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ jobs:
timeout-minutes: 20

steps:
- name: Require main branch
if: ${{ github.ref != 'refs/heads/main' }}
run: |
echo "::error::Deploy Connect must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-demo-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ jobs:
timeout-minutes: 15

steps:
- name: Require main branch
if: ${{ github.ref != 'refs/heads/main' }}
run: |
echo "::error::Deploy Demo Server must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ jobs:
timeout-minutes: 20

steps:
- name: Require main branch
if: ${{ github.ref != 'refs/heads/main' }}
run: |
echo "::error::Deploy Web must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/mobile-ios-eas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ jobs:
cancel-in-progress: false

steps:
- name: Require main branch for TestFlight submission
if: >-
${{ inputs.submit == true
&& github.ref != 'refs/heads/main' }}
run: |
echo "::error::TestFlight submission must run from the main branch, got ${GITHUB_REF}."
exit 1

- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
Loading