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
119 changes: 106 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,31 @@ on:
required: false
default: "scripts/ci/run-release-verification.sh"
type: string
version-script:
description: Optional release version validation/update script path relative to the repository root
required: false
default: "scripts/ci/run-release-version.sh"
type: string
build-script:
description: Optional release artifact build script path relative to the repository root
required: false
default: "scripts/ci/run-release-build.sh"
type: string
publish-script:
description: Release publish script path relative to the repository root
required: false
default: "scripts/ci/run-release-publish.sh"
type: string
release-notes-file:
description: Optional release notes file path produced before GitHub Release creation
required: false
default: "dist/release/RELEASE_NOTES.md"
type: string
default-branch:
description: Default release branch for new minor and major releases
required: false
default: "main"
type: string
create-github-release:
description: Create a GitHub release after publish succeeds
required: false
Expand Down Expand Up @@ -52,23 +72,106 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive release metadata
id: release_meta
env:
RELEASE_TAG: ${{ github.ref_name }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
DEFAULT_BRANCH: ${{ inputs.default-branch }}
run: |
set -euo pipefail

prefix="${TAG_PREFIX}"
tag="${RELEASE_TAG}"
escaped_prefix="$(printf '%s' "${prefix}" | sed 's/[][(){}.^$*+?|\\-]/\\&/g')"

semver_component='(0|[1-9][0-9]*)'
if [[ ! "${tag}" =~ ^${escaped_prefix}${semver_component}\.${semver_component}\.${semver_component}$ ]]; then
echo "Release tag '${tag}' does not match the required exact SemVer pattern '${prefix}X.Y.Z'." >&2
exit 1
fi

major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
commit="$(git rev-list -n 1 "${tag}")"

git fetch origin "+refs/heads/*:refs/remotes/origin/*" --prune

default_ref="origin/${DEFAULT_BRANCH}"
maintenance_ref="origin/release/${major}.${minor}"
allowed=false
if git merge-base --is-ancestor "${commit}" "${default_ref}"; then
allowed=true
elif git show-ref --verify --quiet "refs/remotes/${maintenance_ref}" &&
git merge-base --is-ancestor "${commit}" "${maintenance_ref}"; then
allowed=true
fi

if [[ "${allowed}" != "true" ]]; then
echo "Release tag '${tag}' must point at ${default_ref} or ${maintenance_ref}." >&2
exit 1
fi

previous_tag="$(git tag --list "${prefix}[0-9]*.[0-9]*.[0-9]*" --sort=-v:refname | grep -v "^${tag}$" | head -n 1 || true)"

{
echo "tag=${tag}"
echo "major=${major}"
echo "minor=${minor}"
echo "patch=${patch}"
echo "commit=${commit}"
echo "previous_tag=${previous_tag}"
} >> "${GITHUB_OUTPUT}"
- name: Verify release contract
run: |
if [[ ! -x "${{ inputs.verify-script }}" ]]; then
echo "Missing executable verify script: ${{ inputs.verify-script }}" >&2
exit 1
fi
bash "${{ inputs.verify-script }}"
- name: Run release version hook
run: |
if [[ -x "${{ inputs.version-script }}" ]]; then
bash "${{ inputs.version-script }}"
else
echo "No executable release version hook is configured: ${{ inputs.version-script }}"
fi
- name: Build release artifacts
run: |
if [[ -x "${{ inputs.build-script }}" ]]; then
bash "${{ inputs.build-script }}"
else
echo "No executable release artifact build hook is configured: ${{ inputs.build-script }}"
fi
- name: Publish release artifacts
run: |
if [[ ! -x "${{ inputs.publish-script }}" ]]; then
echo "Missing executable publish script: ${{ inputs.publish-script }}" >&2
exit 1
fi
bash "${{ inputs.publish-script }}"
- name: Create GitHub release
if: ${{ inputs.create-github-release }}
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
if [[ -z "${tag}" ]]; then
echo "GITHUB_REF_NAME is required to create a GitHub release." >&2
exit 1
fi

if [[ -s "${{ inputs.release-notes-file }}" ]]; then
gh release create "${tag}" --notes-file "${{ inputs.release-notes-file }}" ||
gh release edit "${tag}" --notes-file "${{ inputs.release-notes-file }}" --latest
else
gh release create "${tag}" --generate-notes ||
gh release edit "${tag}" --latest
fi
- name: Promote floating SemVer tags
env:
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_TAG: ${{ steps.release_meta.outputs.tag }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
UPDATE_MAJOR_TAG: ${{ inputs.update-major-tag }}
UPDATE_MINOR_TAG: ${{ inputs.update-minor-tag }}
Expand All @@ -79,7 +182,8 @@ jobs:
tag="${RELEASE_TAG}"
escaped_prefix="$(printf '%s' "${prefix}" | sed 's/[][(){}.^$*+?|\\-]/\\&/g')"

if [[ ! "${tag}" =~ ^${escaped_prefix}([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
semver_component='(0|[1-9][0-9]*)'
if [[ ! "${tag}" =~ ^${escaped_prefix}${semver_component}\.${semver_component}\.${semver_component}$ ]]; then
echo "Release tag '${tag}' does not match the required exact SemVer pattern '${prefix}X.Y.Z'." >&2
exit 1
fi
Expand All @@ -102,14 +206,3 @@ jobs:
git tag -fa "${major_tag}" "${commit}" -m "Advance ${major_tag} to ${tag}"
git push origin "refs/tags/${major_tag}" --force
fi
- name: Create GitHub release
if: ${{ inputs.create-github-release }}
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
if [[ -z "${tag}" ]]; then
echo "GITHUB_REF_NAME is required to create a GitHub release." >&2
exit 1
fi
gh release create "${tag}" --generate-notes || gh release edit "${tag}" --latest
48 changes: 42 additions & 6 deletions src/archetypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,14 @@ ${indentBlock(projectIdentityLines(manifest), 4)}
}

function fastChecksScript(manifest: BootstrapManifest): string {
if (manifest.ci.customScripts.fast) {
return `${dedent`
#!/usr/bin/env bash
set -euo pipefail

`}\n${manifest.ci.customScripts.fast.trimEnd()}\n`;
}

const body =
manifest.archetype.kind === "python-service"
? pythonFastChecks()
Expand All @@ -920,6 +928,14 @@ function fastChecksScript(manifest: BootstrapManifest): string {
}

function extendedChecksScript(manifest: BootstrapManifest): string {
if (manifest.ci.customScripts.extended) {
return `${dedent`
#!/usr/bin/env bash
set -euo pipefail

`}\n${manifest.ci.customScripts.extended.trimEnd()}\n`;
}

const body =
manifest.archetype.kind === "python-service"
? pythonExtendedChecks()
Expand All @@ -934,7 +950,15 @@ function extendedChecksScript(manifest: BootstrapManifest): string {
`}\n${body}\n`;
}

function releaseVerificationScript(): string {
function releaseVerificationScript(manifest: BootstrapManifest): string {
if (manifest.ci.customScripts.releaseVerification) {
return `${dedent`
#!/usr/bin/env bash
set -euo pipefail

`}\n${manifest.ci.customScripts.releaseVerification.trimEnd()}\n`;
}

return `${dedent`
#!/usr/bin/env bash
set -euo pipefail
Expand Down Expand Up @@ -1096,32 +1120,44 @@ function workflowPaths(manifest: BootstrapManifest): { app: string[]; ci: string
"docs/bootstrap/**"
];

let paths: { app: string[]; ci: string[]; extended: string[] };

switch (manifest.archetype.kind) {
case "nextjs-web":
return {
paths = {
app: [...common, "app/**", "components/**", "src/**", "public/**", "package.json", "tsconfig.json"],
ci: [...common, ".env.example", "CODEOWNERS"],
extended: ["tests/**", "playwright/**", "docker/**", "infra/**"]
};
break;
case "node-ts-service":
return {
paths = {
app: [...common, "src/**", "tests/**", "package.json", "tsconfig.json"],
ci: [...common, ".env.example", "CODEOWNERS"],
extended: ["docker/**", "infra/**", "ops/**"]
};
break;
case "python-service":
return {
paths = {
app: [...common, "src/**", "tests/**", "pyproject.toml", ".python-version"],
ci: [...common, ".env.example", "CODEOWNERS"],
extended: ["docker/**", "infra/**", "ops/**"]
};
break;
case "generic-empty":
return {
paths = {
app: [...common, "README.md", "docs/**"],
ci: [...common, ".env.example", "CODEOWNERS"],
extended: ["infra/**", "ops/**"]
};
break;
}

return {
app: [...paths.app, ...manifest.ci.appPaths],
ci: [...paths.ci, ...manifest.ci.ciPaths],
extended: [...paths.extended, ...manifest.ci.extendedPaths]
};
}

function setupSteps(manifest: BootstrapManifest): string {
Expand Down Expand Up @@ -1785,7 +1821,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[]
{
path: "scripts/ci/run-release-verification.sh",
reason: "Release verification entrypoint",
contents: releaseVerificationScript(),
contents: releaseVerificationScript(manifest),
executable: true
},
{
Expand Down
48 changes: 48 additions & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ const dependabotSchema = z.object({
ecosystems: z.array(dependabotEcosystemSchema).optional()
});

const macosCheckSchema = z.object({
enabled: z.boolean().optional(),
paths: z.array(z.string().min(1)).optional(),
runsOn: z.array(z.string().min(1)).optional(),
command: z.string().min(1).optional()
});

const customScriptsSchema = z.object({
fast: z.string().min(1).optional(),
extended: z.string().min(1).optional(),
releaseVerification: z.string().min(1).optional()
});

const manifestSchema = z.object({
version: z.literal(1).optional(),
project: z.object({
Expand Down Expand Up @@ -199,6 +212,11 @@ const manifestSchema = z.object({
extendedChecks: z.array(z.string()).optional(),
nightlyCron: z.string().optional(),
additionalWorkflows: z.array(additionalWorkflowSchema).optional(),
appPaths: z.array(z.string().min(1)).optional(),
ciPaths: z.array(z.string().min(1)).optional(),
extendedPaths: z.array(z.string().min(1)).optional(),
macosCheck: macosCheckSchema.optional(),
customScripts: customScriptsSchema.optional(),
dependabot: dependabotSchema.optional(),
aiAttestation: z
.object({
Expand Down Expand Up @@ -385,6 +403,31 @@ function normalizeDependabot(
};
}

function normalizePaths(paths: string[] | undefined): string[] {
return (paths ?? []).map((entry) => entry.replace(/\\/g, "/"));
}

function normalizeMacOSCheck(
check: z.input<typeof macosCheckSchema> | undefined
): BootstrapManifest["ci"]["macosCheck"] {
return {
enabled: check?.enabled ?? false,
paths: normalizePaths(check?.paths),
runsOn: check?.runsOn ?? ["macos-14"],
command: check?.command ?? "xcodebuild -version"
};
}

function normalizeCustomScripts(
scripts: z.input<typeof customScriptsSchema> | undefined
): BootstrapManifest["ci"]["customScripts"] {
return {
...(scripts?.fast ? { fast: scripts.fast } : {}),
...(scripts?.extended ? { extended: scripts.extended } : {}),
...(scripts?.releaseVerification ? { releaseVerification: scripts.releaseVerification } : {})
};
}

export function normalizeManifest(raw: z.input<typeof manifestSchema>): BootstrapManifest {
const parsed = manifestSchema.parse(raw);
const reviewers = (parsed.github?.reviewers ?? []).map((reviewer) => reviewer.replace(/^@/, ""));
Expand Down Expand Up @@ -456,6 +499,11 @@ export function normalizeManifest(raw: z.input<typeof manifestSchema>): Bootstra
extendedChecks: parsed.ci?.extendedChecks ?? ["integration", "release-readiness"],
nightlyCron: parsed.ci?.nightlyCron ?? "0 7 * * *",
additionalWorkflows: normalizeAdditionalWorkflows(parsed.ci?.additionalWorkflows),
appPaths: normalizePaths(parsed.ci?.appPaths),
ciPaths: normalizePaths(parsed.ci?.ciPaths),
extendedPaths: normalizePaths(parsed.ci?.extendedPaths),
macosCheck: normalizeMacOSCheck(parsed.ci?.macosCheck),
customScripts: normalizeCustomScripts(parsed.ci?.customScripts),
dependabot: normalizeDependabot(parsed.ci?.dependabot),
aiAttestation: {
enabled: parsed.ci?.aiAttestation?.enabled ?? false,
Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export interface DependabotConfig {
ecosystems: DependabotEcosystemConfig[];
}

export interface MacOSCheckConfig {
enabled: boolean;
paths: string[];
runsOn: string[];
command: string;
}

export interface CustomScriptsConfig {
fast?: string;
extended?: string;
releaseVerification?: string;
}

export interface OrganizationSecurityDefaults {
dependabotAlerts: boolean;
dependabotSecurityUpdates: boolean;
Expand Down Expand Up @@ -112,6 +125,11 @@ export interface BootstrapManifest {
extendedChecks: string[];
nightlyCron: string;
additionalWorkflows: AdditionalWorkflowConfig[];
appPaths: string[];
ciPaths: string[];
extendedPaths: string[];
macosCheck: MacOSCheckConfig;
customScripts: CustomScriptsConfig;
dependabot: DependabotConfig;
aiAttestation: {
enabled: boolean;
Expand Down
Loading
Loading