ci: add deterministic PR policy and align org templates (PLAT-62) (#115)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions

* docs: align organization templates with Cursor conventions

Refs: PLAT-62

* ci: add deterministic PR policy gate

Refs: PLAT-62

* fix(ci): grandfather unchanged workflow policy debt

Refs: PLAT-62

* fix(ci): address PR policy security review

Refs: PLAT-62

* fix(ci): scan copied workflow files

Refs: PLAT-62

* fix(ci): close remaining workflow policy bypasses

Refs: PLAT-62

* fix(policy): reject uses block scalar action refs

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(policy): preserve line-specific violation fingerprints

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
This commit is contained in:
Adam Moussa 2026-08-03 20:30:32 -04:00 • committed by GitHub
parent b94062bd86
commit 9c1ecf9428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3450 additions and 55 deletions

View file

@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Internal IT support
url: https://seahaven.atlassian.net/jira/software/projects/INFRA
about: For operational issues, file an INFRA Jira ticket instead.
- name: Jira — DEV / PLAT / SEC
url: https://seahaven.atlassian.net/jira
about: File all org work in Jira (DEV, PLAT, or SEC). INFRA is a closed archive. GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe.

View file

@ -15,7 +15,6 @@ assignees: amoussa1229
## AWS / integration impact
- New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway):
- Slack app(s) involved:
- Confluence Architecture Map update needed: yes / no
## Alternatives considered
<!-- Other approaches and why they were rejected. -->

View file

@ -21,6 +21,4 @@ assignees: amoussa1229
<!-- Exact steps to revert: prior stack version, DeletionPolicy considerations, data restore. -->
## Documentation
- [ ] Confluence Architecture Map (id 1540098) update queued
- [ ] README updated in same PR
- [ ] Project memory entry queued

View file

@ -1,8 +1,14 @@
<!--
PR conventions — see engineering-handbook/pull-requests.md
- Title: imperative mood, under 70 chars, describe the change not the ticket (e.g. "Add receipt parser Lambda", not "PROJ-123" or "Bug fix").
- Scope: one logical change per PR. If the title needs an "and", split it.
- Jira: put the issue key in the branch name or this PR title (e.g. [PROJ-123]) to link the PR into the Jira issue's development panel. Omit if the work has no ticket.
PR conventions
- Title format: type(scope): description (DEV-123)
- type ∈ feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release
- Active Jira projects: DEV (product), PLAT (platform), SEC (security). INFRA is a closed archive.
- The Jira key is required at the end of the title in parentheses.
- Jira-exempt only: Dependabot PRs and permission-controlled emergency reverts.
- Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description.
Branch names do not contain Jira keys.
- Scope: one logical change per PR. If the title needs "and", split it.
- Body: state verifiable facts about the change and validation. Do not cite the handbook or add AI-attribution footers.
-->
## Summary
@ -15,13 +21,4 @@ PR conventions — see engineering-handbook/pull-requests.md
<!-- What tests were added, updated, or run. If no automated tests, explain the manual testing. -->
## Notes
<!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Delete this section if empty. -->
## Sea Haven checklist
- [ ] CDK diff / SAM changeset reviewed (if infra change)
- [ ] Secrets added to Parameter Store / Secrets Manager (not hardcoded)
- [ ] DynamoDB PITR verified on new tables
- [ ] Slack notification tested in staging
- [ ] Confluence Architecture Map updated
- [ ] Memory update queued (if new repo/stack)
- [ ] Cross-review requested (if IAM or Lambda handler signature change)
<!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Use None. if empty. -->

View file

@ -4,6 +4,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
update-types:

View file

@ -0,0 +1,898 @@
name: PR Policy
# Reusable PR metadata gate for all Sea-Haven-Industries repos.
#
# Validates pull-request metadata — title convention, branch naming, body
# structure, commit subjects, Jira existence, AI attribution footers, and
# workflow file pin compliance — without executing any PR code or checking
# out the repository. All checks run through the GitHub API only.
#
# Callers trigger this on `pull_request` (NOT pull_request_target) with event
# types: opened, reopened, synchronize, edited, labeled, unlabeled,
# ready_for_review. The check-run name is `<caller-job-id> / pr`; the
# canonical caller job id is `policy`, producing the context `policy / pr`.
#
# Secrets are optional at the declaration level. For human PRs that include a
# Jira key, all three must be configured or the check fails closed (POLICY-INFRA).
# Dependabot skips Jira/branch/body checks but still runs commit-subject and
# workflow supply-chain checks.
#
# Emergency-revert exemption: when the title type is `revert`, the PR has no
# Jira key in the title, and the `emergency-revert` label is present on the PR,
# a candidate exemption is computed before title validation so the Jira key is
# not required in the title. The exemption is confirmed by verifying that the
# label was applied by a collaborator with maintain or admin permission. Any
# pagination truncation of the event timeline is POLICY-INFRA — partial history
# is never trusted. Unauthorized/null-actor/bot results add a violation.
# Branch, body, and commit checks remain regardless.
#
# Known platform limitation: metadata edits (labels, title changes) made via
# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation
# that applies labels must use a GitHub App token or a PAT so the policy gate
# re-runs automatically after the label is applied.
#
# Caller example:
# jobs:
# policy:
# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<sha> # vX.Y.Z
# secrets:
# JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
# JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
on:
workflow_call:
secrets:
JIRA_CLOUD_ID:
required: false
JIRA_SERVICE_ACCOUNT_EMAIL:
required: false
JIRA_API_TOKEN:
required: false
permissions:
contents: read
issues: read
pull-requests: read
jobs:
pr:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Validate PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
with:
script: |
// ── Pure validation functions ────────────────────────────────────────────
// Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST.
// These functions have no side effects and make no API calls.
const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release'];
const REQUIRED_H2 = ['Summary','Validation','Tests','Notes'];
// AI-attribution footer patterns — case-insensitive, multiline.
// Matches Co-authored-by: trailers naming known AI tools and "Generated by/with"
// phrases. Does NOT flag generic prose like "uses AI" or "AI-powered".
// GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+.
const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im;
function validateTitle(title, isDependabot, jiraMaybeExempt) {
const errs = [];
if (title.length > 72) errs.push('Title is ' + title.length + ' chars — max 72');
const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC)-\d+\))?$/);
if (!m) {
errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' '));
return errs;
}
const desc = m[4];
const jiraSuffix = m[5];
if (desc.endsWith('.')) errs.push('Description must not end with a period');
if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter');
if (!isDependabot && !jiraSuffix && !jiraMaybeExempt) {
errs.push('Missing Jira key — expected (DEV-NNN), (PLAT-NNN), or (SEC-NNN) at end of title');
}
return errs;
}
function getJiraKey(title) {
const m = title.match(/\((DEV|PLAT|SEC)-(\d+)\)$/);
return m ? m[1] + '-' + m[2] : null;
}
function validateBranch(branch, isDependabot) {
if (isDependabot) return [];
const errs = [];
// Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen.
const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
if (!m) {
errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release');
return errs;
}
if (/(?:DEV|PLAT|SEC|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key');
return errs;
}
function validateBody(rawBody, isDependabot) {
if (isDependabot) return [];
if (!rawBody || !rawBody.trim()) return ['PR body is empty'];
const errs = [];
// Strip fenced code blocks line-by-line before scanning headings.
// Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed
// (CommonMark spec). Use charCode to avoid literal backtick in source
// (which confuses actionlint's expression scanner).
const TICK = String.fromCharCode(0x60);
const bodyLines = rawBody.split('\n');
const stripped = [];
let inFence = false;
let fenceChar = '';
let fenceLen = 0;
const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})');
for (const line of bodyLines) {
if (!inFence) {
const fm = fenceRe.exec(line);
if (fm) {
inFence = true;
fenceChar = fm[1][0];
fenceLen = fm[1].length;
stripped.push('');
} else {
stripped.push(line);
}
} else {
const fm = fenceRe.exec(line);
if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) {
inFence = false;
stripped.push('');
} else {
stripped.push('');
}
}
}
const cleaned = stripped.join('\n').replace(/<!--[\s\S]*?-->/g, '');
const h2s = Array.from(cleaned.matchAll(/^## (.+)$/gm)).map(function(x) { return x[1].trim(); });
if (h2s.length !== 4) {
errs.push('Body must have exactly 4 ## headings (Summary/Validation/Tests/Notes), found ' + h2s.length + (h2s.length ? ': ' + h2s.join(', ') : ''));
return errs;
}
for (let i = 0; i < 4; i++) {
if (h2s[i] !== REQUIRED_H2[i]) errs.push('Heading ' + (i + 1) + ': expected "## ' + REQUIRED_H2[i] + '", got "## ' + h2s[i] + '"');
}
const sectionParts = cleaned.split(/^(?=## )/m).filter(function(p) { return p.startsWith('## '); });
for (let i = 0; i < Math.min(sectionParts.length, 4); i++) {
const content = sectionParts[i].replace(/^## [^\n]*\n?/, '').trim();
if (!content) errs.push('## ' + REQUIRED_H2[i] + ' section is empty');
}
return errs;
}
function validateCommitSubject(subject) {
const errs = [];
if (subject.length > 72) errs.push('Commit subject is ' + subject.length + ' chars — max 72');
const m = subject.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+)$/);
if (!m) {
errs.push('Not conventional: "' + subject.slice(0, 60) + (subject.length > 60 ? '\u2026' : '') + '"');
return errs;
}
const desc = m[4];
if (!/^[a-z]/.test(desc)) errs.push('Commit description must start with a lowercase letter');
if (desc.endsWith('.')) errs.push('Commit description must not end with a period');
if (/\s+\((DEV|PLAT|SEC)-\d+\)$/i.test(subject)) errs.push('Commit subject must not carry a Jira key suffix — only the PR title does');
return errs;
}
function detectAiFooter(text) {
return AI_FOOTER_RE.test(text);
}
function isWorkflowFilename(filename) {
return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) ||
/^workflow-templates\/[^/]+\.ya?ml$/.test(filename);
}
// Matches action manifests at any depth (e.g. .github/actions/**/action.yml).
function isActionManifestFilename(filename) {
return /(?:^|\/)action\.ya?ml$/.test(filename);
}
// Combined predicate — any file that the supply-chain scanner must process.
function isPolicyFilename(filename) {
return isWorkflowFilename(filename) || isActionManifestFilename(filename);
}
// Returns 'workflow', 'action', or null for non-policy files.
// Used by classifyFileStatus to prevent cross-kind rename diffing.
function policyFileKind(filename) {
if (isWorkflowFilename(filename)) return 'workflow';
if (isActionManifestFilename(filename)) return 'action';
return null;
}
// FNV-1a 32-bit hash of a string — used to produce content fingerprints
// for line-specific violations so changing the payload changes the
// fingerprint even when the violation remains on the same line.
function fnv1a32(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 16777619) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
// Strip the trailing [fnv:XXXXXXXX] content-hash token from a violation
// string before emitting it to users. The hash is purely internal.
function stripHash(msg) {
return msg.replace(/ \[fnv:[0-9a-f]{8}\]$/, '');
}
// Deterministic line scanner for workflow YAML content.
//
// Block-scalar tracking: any YAML key whose value begins with | or >
// (including explicit indent/chomp forms |2, |2-, |-2, >+2, etc.)
// starts a block scalar. Lines inside ANY block scalar are not parsed
// as structural YAML keys — they are content. For run: block scalars,
// the content is still scanned for expression injection (expressions
// must flow through env:). uses: block scalars are rejected because an
// action ref must be an inline scalar to validate its immutable pin.
// For other non-run block scalars (e.g. script:, name:), the content
// is skipped entirely — no uses: or run: detection.
//
// Quoted keys: "uses", 'uses', "run", 'run', "permissions" are all
// recognized in addition to their unquoted forms.
//
// Quoted action refs: `uses: "owner/repo@sha" # vX.Y.Z` correctly
// parses the comment outside the closing quote as the version annotation.
//
// Fails closed on YAML forms the line scanner cannot safely resolve:
// - Escaped/encoded keys in double-quoted strings ("u\u0073es")
// - Flow-style sequence steps (- { uses: ... }, - { run: ... })
// - YAML aliases/anchors on run:, uses:, or permissions: values
//
// All-zero SHAs and v0.0.0 placeholder pins are rejected.
// opts.requirePermissions (default true) — workflow files require a top-level
// permissions: key; action manifests do not support it and must pass false.
function validateWorkflowContent(content, filename, opts) {
const requirePermissions = !opts || opts.requirePermissions !== false;
const errs = [];
const EXPR_OPEN = '$' + '{{';
// 1. Top-level permissions key required (workflows only; not action manifests).
if (requirePermissions) {
if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) {
errs.push(filename + ': missing top-level "permissions:" key');
}
// 1b. Top-level permissions alias check.
if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) {
errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map');
}
}
// 2. Line-by-line scan.
// inBlock: currently inside a block scalar
// blockIndent: indent of the key that opened the block scalar
// blockIsRun: the block belongs to a run: key (check expressions)
const lines = content.split('\n');
let inBlock = false;
let blockIndent = -1;
let blockIsRun = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const rawIndent = (line.match(/^([ \t]*)/) || ['', ''])[1].length;
// ── Inside a block scalar ────────────────────────────────────────
if (inBlock) {
if (line.trim() === '') continue;
if (rawIndent > blockIndent) {
// Content of block scalar.
// Only flag expression injection for run: block scalars.
if (blockIsRun && line.includes(EXPR_OPEN)) {
errs.push(filename + ':' + (i + 1) + ': run: block contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']');
}
continue;
}
// Indent at or below the block key — exit block scalar.
inBlock = false;
blockIndent = -1;
blockIsRun = false;
// Fall through to process this line as structural YAML.
}
// ── Escaped/encoded double-quoted key — fail closed ───────────────
// A double-quoted key containing \ cannot be reliably resolved by
// the line scanner (e.g. "u\u0073es" parses as "uses" in YAML).
// Reject any such key at the structural position.
if (/^[ \t]*(?:-[ \t]+)?"[^"]*\\[^"]*":/.test(line)) {
errs.push(filename + ':' + (i + 1) + ': escaped key in double-quoted string is not supported — use literal key names (run:, uses:, permissions:) [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
// ── Structural key with whitespace before colon — fail closed ────
// YAML permits `key : value` but the scanner matches `key:` forms
// only; a space before the colon silently bypasses all checks.
// Reject any structural key (uses, run, steps — quoted or plain,
// sequence-item or mapping) that has whitespace before the colon.
if (/^[ \t]*(?:-[ \t]+)?(?:"(?:uses|run|steps)"|'(?:uses|run|steps)'|uses|run|steps)[ \t]+:/.test(line)) {
errs.push(filename + ':' + (i + 1) + ': structural key with whitespace before ":" is not supported — remove the space before the colon [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
// ── Sequence-item anchor declaration — fail closed ───────────────
// Any line of the form `- &anchor` (with or without a mapping on
// the same line) is rejected. A standalone `- &name` can be
// followed on the next line by a flow-style mapping that the
// scanner would then misread as structural YAML. The multiline
// alias form `- *name` on subsequent steps is also unreachable
// without first declaring such an anchor. Reject unconditionally.
if (/^[ \t]*-[ \t]+&\S+/.test(line)) {
errs.push(filename + ':' + (i + 1) + ': sequence-item anchor declaration (&name) is not supported — anchors on steps may introduce flow mappings the scanner cannot safely resolve [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
// ── Flow-style sequence step — fail closed only for structural keys ─
// `- { ... }` form cannot be safely resolved when it contains a
// structural run: or uses: key (including quoted or escaped forms).
// Non-step data objects like `- { os: ubuntu, node: 24 }` are
// allowed — they cannot contain action refs or run scripts.
// `permissions: {}` is a mapping value (not a sequence item),
// so it is unaffected by this check entirely.
if (/^[ \t]*-[ \t]+\{[^}]/.test(line)) {
const braceIdx = line.indexOf('{');
const flowContent = line.slice(braceIdx);
if (/[{,]\s*(?:"uses"|'uses'|uses|"run"|'run'|run|"[^"]*\\[^"]*")\s*:/.test(flowContent)) {
errs.push(filename + ':' + (i + 1) + ': flow-style step mapping with structural "run:" or "uses:" key is not supported — use block mapping style [fnv:' + fnv1a32(line.trim()) + ']');
}
continue;
}
// ── Flow-style steps array — fail closed ─────────────────────────
// `steps: [...]` and `steps: [` (multiline opener) cannot be
// safely resolved. Exception: `steps: []` is an empty array
// with no execution and is explicitly allowed.
// Quoted ("steps") and unquoted forms are both detected.
const stepsFlowM = line.match(/^[ \t]*(?:"steps"|'steps'|steps):[ \t]*\[(.*)$/);
if (stepsFlowM) {
const inner = stepsFlowM[1].trimStart();
if (!/^\]\s*(#.*)?$/.test(inner)) {
errs.push(filename + ':' + (i + 1) + ': flow-style "steps" array is not supported — use block-style steps list [fnv:' + fnv1a32(line.trim()) + ']');
}
continue;
}
// ── Any block scalar key detection (| or >) ──────────────────────
// Matches quoted ("key", 'key') and unquoted (key) key names,
// with optional sequence-item prefix (- ), followed by a block
// indicator (| or > with optional explicit-indent/chomp modifiers).
// YAML block scalar header forms: | |2 |- |+ |2- |2+ |-2 |+2
// and equivalents with > (folded). Both digit-first and chomp-first
// orderings are recognized per the YAML 1.2 spec.
// Groups: [1]=indent [2]=full-key [3]=dq-content [4]=sq-content [5]=unquoted [6]=indicator
const blockM = line.match(/^([ \t]*)(?:-[ \t]+)?("([^"]*)"|'([^']*)'|([\w-]+)):[ \t]*([|>](?:[1-9][-+]?|[-+][1-9]?)?)[ \t]*(?:#.*)?$/);
if (blockM) {
const keyName = blockM[3] !== undefined ? blockM[3] : (blockM[4] !== undefined ? blockM[4] : (blockM[5] || ''));
if (keyName === 'uses') {
errs.push(filename + ':' + (i + 1) + ': uses: block scalar is not supported — action refs must be inline and pinned to an immutable SHA [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
inBlock = true;
blockIndent = blockM[1].length;
blockIsRun = (keyName === 'run');
continue;
}
// ── Inline run: value (no block indicator) ───────────────────────
// Handles mapping form and sequence-item form; quoted and unquoted key.
// A run: &anchor | line (anchor before block indicator) falls here
// because blockM cannot match it; the & causes the alias check below.
const inlineRunM = line.match(/^[ \t]*(?:-[ \t]+)?(?:"run"|'run'|run):[ \t]+(.*)$/);
if (inlineRunM) {
const runVal = inlineRunM[1].trimStart();
// A YAML alias is *name; an anchor is &name (non-whitespace after &).
// Ordinary shell & like 'echo "R&D build"' does not start with * or &word.
if (runVal[0] === '*' || /^&\S/.test(runVal)) {
errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "run:" value is not supported — inline the run script [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
if (inlineRunM[1].includes(EXPR_OPEN)) {
errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']');
}
continue;
}
// ── uses: key detection ──────────────────────────────────────────
// Handles mapping form and sequence-item form; quoted and unquoted key.
const usesM = line.match(/^[ \t]+(?:-[ \t]+)?(?:"uses"|'uses'|uses):[ \t]+(.+)$/);
if (!usesM) continue;
// Parse the action ref — handle quoted scalar with comment outside quotes.
const rawVal = usesM[1].trim();
// Reject YAML alias/anchor in uses: value.
// An alias is *name; an anchor is &name (non-whitespace after &).
if (rawVal[0] === '*' || /^&\S/.test(rawVal)) {
errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "uses:" value is not supported — inline the action ref [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
let ref;
let extComment = '';
if (rawVal[0] === '"' || rawVal[0] === "'") {
const q = rawVal[0];
const closeIdx = rawVal.indexOf(q, 1);
if (closeIdx !== -1) {
ref = rawVal.slice(1, closeIdx);
const rest = rawVal.slice(closeIdx + 1).trimStart();
if (rest[0] === '#') extComment = rest;
} else {
ref = rawVal; // malformed quote — treat as unquoted
}
} else {
ref = rawVal;
}
// Local action references cannot be validated — the scanner
// does not recursively resolve action manifests. Inline the
// action logic or replace with an immutable remote SHA pin.
if (ref.startsWith('./')) {
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" local action reference is not supported — inline the action or use an immutable remote SHA pin');
continue;
}
// Docker refs require an immutable sha256 digest pin.
// Mutable tags, :latest, and bare image names are rejected.
// No # vX.Y.Z comment is required because the digest is the
// immutable identity.
if (ref.startsWith('docker://')) {
if (!/^docker:\/\/.+@sha256:[0-9a-f]{64}$/.test(ref)) {
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" docker:// ref must be pinned by immutable digest (docker://<image>@sha256:<64 lowercase hex>)');
}
continue;
}
// Validate SHA + version comment.
// For quoted refs, combine the unquoted value with any external comment.
const forShaCheck = extComment ? ref + ' ' + extComment : ref;
const shaMatch = forShaCheck.match(/@([0-9a-f]{40})[ \t]+#[ \t]+v(\d+)\.(\d+)\.(\d+)$/i);
if (!shaMatch) {
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" must be pinned to a 40-char SHA with "# vX.Y.Z" comment');
continue;
}
// Reject all-zero placeholder SHA.
if (/^0{40}$/.test(shaMatch[1])) {
const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" uses a placeholder all-zero SHA — replace with the actual release SHA');
}
// Reject v0.0.0 placeholder version.
if (shaMatch[2] === '0' && shaMatch[3] === '0' && shaMatch[4] === '0') {
const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" uses placeholder version v0.0.0 — update to the actual release version');
}
}
return errs;
}
// Retry-After header parser — supports integer seconds and HTTP-date.
// Returns milliseconds to wait, capped at 60000. Returns 0 for invalid
// or non-positive values so the caller uses exponential fallback instead.
// nowMs is injectable for testing; defaults to Date.now().
function parseRetryAfterMs(header, nowMs) {
if (!header) return 0;
const secs = parseInt(header, 10);
if (!isNaN(secs) && secs > 0) return Math.min(secs * 1000, 60000);
const date = new Date(header);
if (!isNaN(date.getTime())) {
const ms = date.getTime() - (nowMs !== undefined ? nowMs : Date.now());
return ms > 0 ? Math.min(ms, 60000) : 0;
}
return 0;
}
// Pure helpers for commit and file count limit checks.
function checkCommitLimit(prCommits) {
if (prCommits > 250) {
return 'POLICY-INFRA: PR has ' + prCommits + ' commits — GitHub REST API caps listCommits at 250; not all commit subjects can be validated';
}
return null;
}
function checkFilesLimit(prChangedFiles) {
if (prChangedFiles > 3000) {
return 'POLICY-INFRA: PR has ' + prChangedFiles + ' changed files — GitHub REST API caps listFiles at 3000; not all workflow files can be validated';
}
return null;
}
// Normalize a validateWorkflowContent error string to a diff fingerprint.
// Per-line locations are intentionally preserved so moving a grandfathered
// violation to a different execution path is treated as a new violation.
// Content-identifying tokens (action ref, expression text) are preserved.
function normalizeViolationFingerprint(err) {
return err;
}
// Diff head vs base violations using location-preserving fingerprints
// with multiplicity. For each fingerprint, up to base-count head
// violations of that fingerprint are considered pre-existing; the
// remainder are new. Violations are returned in head-file order.
function filterNewViolations(headErrs, baseErrs) {
const baseCounts = new Map();
for (const e of baseErrs) {
const fp = normalizeViolationFingerprint(e);
baseCounts.set(fp, (baseCounts.get(fp) || 0) + 1);
}
const remaining = new Map(baseCounts);
const result = [];
for (const e of headErrs) {
const fp = normalizeViolationFingerprint(e);
const rem = remaining.get(fp) || 0;
if (rem > 0) {
remaining.set(fp, rem - 1);
} else {
result.push(e);
}
}
return result;
}
// Classify the status of a pull-request file for workflow scanning.
// Returns one of four action objects:
// { action: 'skip' } — removed or unchanged; no validation
// { action: 'full' } — added or copied; full validation, no baseline
// { action: 'diff', basePath: string } — modified/changed or renamed-from-workflow;
// validate head, diff against base at basePath
// { action: 'infra', reason: string } — unknown status; report POLICY-INFRA
// isWfFn must be the isWorkflowFilename predicate (injectable for testing).
function classifyFileStatus(file, isWfFn) {
const s = file.status;
if (s === 'removed' || s === 'unchanged') return { action: 'skip' };
if (s === 'added' || s === 'copied') return { action: 'full' };
if (s === 'modified' || s === 'changed') return { action: 'diff', basePath: file.filename };
if (s === 'renamed') {
if (file.previous_filename &&
isWfFn(file.previous_filename) &&
policyFileKind(file.previous_filename) === policyFileKind(file.filename)) {
return { action: 'diff', basePath: file.previous_filename };
}
return { action: 'full' };
}
return { action: 'infra', reason: 'unknown file status "' + s + '" for ' + file.filename };
}
// ── Test escape ──────────────────────────────────────────────────────────
// Set PR_POLICY_TEST=1 to extract pure functions without hitting any API.
if (process.env.PR_POLICY_TEST === '1') {
return {
validateTitle,
getJiraKey,
validateBranch,
validateBody,
validateCommitSubject,
detectAiFooter,
isWorkflowFilename,
isActionManifestFilename,
isPolicyFilename,
policyFileKind,
validateWorkflowContent,
parseRetryAfterMs,
checkCommitLimit,
checkFilesLimit,
normalizeViolationFingerprint,
filterNewViolations,
fnv1a32,
stripHash,
classifyFileStatus,
};
}
// ── Jira API helper ──────────────────────────────────────────────────────
// Retries on 429/5xx up to 3 times with Retry-After header support.
// On the final attempt (attempt === 3), 429/5xx falls through to the
// status-specific throw. Never logs secrets or response bodies.
async function jiraGetIssue(cloudId, issueKey, email, token) {
const https = require('https');
const apiPath = '/ex/jira/' + cloudId + '/rest/api/3/issue/' + issueKey + '?fields=key';
const authHeader = 'Basic ' + Buffer.from(email + ':' + token).toString('base64');
for (let attempt = 0; attempt <= 3; attempt++) {
const result = await new Promise(function(resolve, reject) {
const req = https.request({
hostname: 'api.atlassian.com',
path: apiPath,
method: 'GET',
headers: { 'Authorization': authHeader, 'Accept': 'application/json' },
}, function(res) {
const chunks = [];
res.on('data', function(c) { chunks.push(c); });
res.on('end', function() {
resolve({ status: res.statusCode, retryAfter: res.headers['retry-after'], body: Buffer.concat(chunks).toString('utf8') });
});
});
req.on('error', reject);
req.end();
});
if (result.status === 200) {
let parsed;
try { parsed = JSON.parse(result.body); } catch (_) {
const e = new Error('Jira API returned non-JSON'); e.isInfra = true; throw e;
}
if (parsed.key !== issueKey) throw new Error('Jira returned key "' + parsed.key + '" but expected "' + issueKey + '"');
return parsed;
}
if (result.status === 404) throw new Error('Jira issue ' + issueKey + ' not found');
if (result.status === 401 || result.status === 403) {
const e = new Error('Jira auth rejected (HTTP ' + result.status + ')'); e.isInfra = true; throw e;
}
if ((result.status === 429 || result.status >= 500) && attempt < 3) {
const headerMs = parseRetryAfterMs(result.retryAfter);
const delayMs = headerMs > 0 ? headerMs : Math.min(2000 * (attempt + 1), 30000);
await new Promise(function(r) { setTimeout(r, delayMs); });
continue;
}
const e = new Error('Jira API returned HTTP ' + result.status); e.isInfra = true; throw e;
}
}
// ── Main ─────────────────────────────────────────────────────────────────
const violations = [];
const infraCodes = [];
let infraFailed = false;
const MAX_ANNOTATIONS = 50;
function addViolation(msg) { violations.push(msg); }
function addInfra(msg) { infraCodes.push(msg); infraFailed = true; }
const repoOwner = context.repo.owner;
const repoName = context.repo.repo;
const pr = context.payload.pull_request;
const prNum = pr.number;
const isDep = pr.user.login === 'dependabot[bot]';
const titleTypeMatch = pr.title.match(/^([a-z]+)/);
const titleType = titleTypeMatch ? titleTypeMatch[1] : '';
// Pre-compute emergency-revert candidate before title validation.
// Only a revert title that LACKS a Jira suffix triggers emergency
// authorization; a revert title that already carries a Jira key does not.
const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; });
const titleHasJira = !!getJiraKey(pr.title);
const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira;
// 1 — title convention
for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e);
// 2 — branch naming (Dependabot exempt)
for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e);
// 3 — body structure (Dependabot exempt)
for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e);
// 4 — AI attribution footer in title/body
if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) {
addViolation('AI attribution footer detected in PR title or body');
}
// 5 — commits: subject convention + AI footer
// GitHub REST API caps listCommits at 250 total. Fail infra immediately
// when pr.commits exceeds that limit; compare fetched count to detect
// API truncation.
{
const commitLimitErr = checkCommitLimit(pr.commits);
if (commitLimitErr) addInfra(commitLimitErr);
let commitPage = 1;
let commitMore = true;
let totalFetched = 0;
while (commitMore) {
let resp;
try {
resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage });
} catch (err) {
addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message);
break;
}
const commits = resp.data;
const link = (resp.headers && resp.headers.link) ? resp.headers.link : '';
totalFetched += commits.length;
if (!link.includes('rel="next"') || commits.length === 0) commitMore = false;
for (const c of commits) {
const subject = c.commit.message.split('\n')[0];
for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e);
if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected');
}
commitPage++;
}
if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) {
addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected');
}
}
// 6 — emergency-revert authorisation
// Event timeline truncation is always POLICY-INFRA regardless of whether
// an earlier label event was found — partial history is never trusted.
let jiraExempt = isDep;
let emergencyAuthFailed = false;
if (isEmergencyCandidate) {
try {
let evPage = 1;
let evMore = true;
let latestLabelEvent = null;
let evTruncated = false;
while (evMore) {
const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage });
const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : '';
for (const ev of evResp.data) {
if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev;
}
if (!evLink.includes('rel="next"') || evResp.data.length === 0) {
evMore = false;
} else if (evPage >= 20) {
evMore = false;
evTruncated = true;
}
evPage++;
}
if (evTruncated) {
// Partial history cannot verify the most-recent label event.
// An earlier maintainer event might have been superseded.
addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required');
emergencyAuthFailed = true;
} else if (!latestLabelEvent) {
addViolation('emergency-revert: label present but no label event found in timeline — Jira key required');
} else if (!latestLabelEvent.actor) {
addViolation('emergency-revert: label event actor is null — Jira key required');
} else if (latestLabelEvent.actor.type === 'Bot') {
addViolation('emergency-revert: label applied by a bot — Jira key required');
} else {
const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login });
if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') {
jiraExempt = true;
} else {
addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required');
}
}
} catch (err) {
addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message);
emergencyAuthFailed = true;
}
}
// 7 — Jira existence (Dependabot exempt; emergency-revert may be exempt)
// Skip entirely when emergency auth already produced an infra error to
// avoid a redundant credential error on a PR that has no Jira key.
const jiraKey = isDep ? null : getJiraKey(pr.title);
if (!jiraExempt && jiraKey && !emergencyAuthFailed) {
const cloudId = process.env.JIRA_CLOUD_ID || '';
const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || '';
const jiraToken = process.env.JIRA_API_TOKEN || '';
if (!cloudId || !jiraEmail || !jiraToken) {
addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs');
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) {
addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID');
} else {
try {
await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken);
} catch (err) {
if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message);
else addViolation('Jira: ' + err.message);
}
}
}
// 8 — workflow file supply-chain checks (diff-mode)
// Only NEW violations relative to the base branch are reported.
// Added files have no baseline and must be fully compliant.
// Modified/renamed files are diffed: base content is fetched at
// pr.base.sha (using previous_filename for renames). Base fetch
// failures are POLICY-INFRA — partial history is never silently
// grandfathered. Deleted files are skipped.
// GitHub REST API caps listFiles at 3000.
try {
const filesLimitErr = checkFilesLimit(pr.changed_files);
if (filesLimitErr) addInfra(filesLimitErr);
let filesPage = 1;
let filesMore = true;
let totalFilesFetched = 0;
while (filesMore) {
const filesResp = await github.rest.pulls.listFiles({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: filesPage });
const filesLink = (filesResp.headers && filesResp.headers.link) ? filesResp.headers.link : '';
totalFilesFetched += filesResp.data.length;
if (!filesLink.includes('rel="next"') || filesResp.data.length === 0) filesMore = false;
for (const file of filesResp.data) {
if (!isPolicyFilename(file.filename)) continue;
const cls = classifyFileStatus(file, isPolicyFilename);
if (cls.action === 'skip') continue;
if (cls.action === 'infra') {
addInfra('POLICY-INFRA: ' + cls.reason + '; skipping workflow validation');
continue;
}
// Fetch HEAD content via blob SHA.
let headContent;
try {
const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha });
const raw = blobResp.data;
const enc = raw.encoding === 'base64' ? 'base64' : 'utf8';
headContent = Buffer.from(raw.content, enc).toString('utf8');
} catch (blobErr) {
addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message);
continue;
}
// Action manifests do not support top-level permissions:.
const wfOpts = policyFileKind(file.filename) === 'action' ? { requirePermissions: false } : {};
const headErrs = validateWorkflowContent(headContent, file.filename, wfOpts);
if (cls.action === 'full') {
// No baseline — added, copied, or renamed-from-non-policy path.
for (const e of headErrs) addViolation(e);
} else {
// diff — modified, changed, or renamed-from-policy path.
// Both head and base violations use file.filename so fingerprints match.
let baseErrs = [];
try {
const baseResp = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, path: cls.basePath, ref: pr.base.sha });
const baseRaw = baseResp.data;
const baseEnc = baseRaw.encoding === 'base64' ? 'base64' : 'utf8';
const baseContent = Buffer.from(baseRaw.content, baseEnc).toString('utf8');
baseErrs = validateWorkflowContent(baseContent, file.filename, wfOpts);
} catch (baseErr) {
addInfra('POLICY-INFRA: Cannot fetch base content for ' + file.filename + ' at ' + pr.base.sha + ': ' + baseErr.message);
continue;
}
for (const e of filterNewViolations(headErrs, baseErrs)) addViolation(e);
}
}
filesPage++;
}
if (pr.changed_files <= 3000 && totalFilesFetched > 0 && totalFilesFetched !== pr.changed_files) {
addInfra('POLICY-INFRA: Fetched ' + totalFilesFetched + ' changed files but PR reports ' + pr.changed_files + ' — API truncation suspected');
}
} catch (err) {
addInfra('POLICY-INFRA: Failed to list PR files: ' + err.message);
}
// 9 — emit annotations + step summary, then fail once
// Both annotations and summary entries are capped at MAX_ANNOTATIONS
// to prevent oversized outputs on PRs with many violations.
const annotated = violations.slice(0, MAX_ANNOTATIONS);
for (const msg of annotated) core.error(stripHash(msg));
for (const msg of infraCodes.slice(0, MAX_ANNOTATIONS)) core.error(msg);
if (violations.length > MAX_ANNOTATIONS) {
core.warning((violations.length - MAX_ANNOTATIONS) + ' additional violation(s) suppressed (max ' + MAX_ANNOTATIONS + ' annotations)');
}
const totalCount = violations.length + infraCodes.length;
const summaryParts = [totalCount === 0 ? '## PR Policy: All checks passed \u2713' : '## PR Policy: ' + totalCount + ' issue(s) found'];
if (violations.length > 0) {
summaryParts.push('', '### Policy violations');
const shownV = violations.slice(0, MAX_ANNOTATIONS);
for (const msg of shownV) summaryParts.push('- ' + stripHash(msg));
if (violations.length > MAX_ANNOTATIONS) {
summaryParts.push('- _...and ' + (violations.length - MAX_ANNOTATIONS) + ' more violation(s) not shown_');
}
}
if (infraCodes.length > 0) {
summaryParts.push('', '### Infrastructure failures');
const shownI = infraCodes.slice(0, MAX_ANNOTATIONS);
for (const msg of shownI) summaryParts.push('- ' + msg);
if (infraCodes.length > MAX_ANNOTATIONS) {
summaryParts.push('- _...and ' + (infraCodes.length - MAX_ANNOTATIONS) + ' more infra error(s) not shown_');
}
}
await core.summary.addRaw(summaryParts.join('\n')).write();
if (violations.length > 0 || infraFailed) {
core.setFailed('PR policy: ' + violations.length + ' violation(s), ' + infraCodes.length + ' infrastructure error(s)');
}

View file

@ -4,7 +4,7 @@ name: CD — .NET Elastic Beanstalk
#
# jobs:
# deploy:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # main
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # v1.0.4
# with:
# project: "Api.Example/Api.Example.csproj"
# eb-application: "example-api"

View file

@ -35,7 +35,7 @@ name: CI — Mobile iOS
# Caller example:
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # main
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # v1.0.4
# with:
# working-directory: mobile
# cache-dependency-path: mobile/package-lock.json

View file

@ -15,7 +15,7 @@ name: CI — Static Site
# Caller example (build mode):
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # main
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # v1.0.4
# with:
# build-command: "npx @11ty/eleventy"
# check-dir: "_site"

View file

@ -13,7 +13,7 @@ name: CI — TypeScript Frontend
# Caller example:
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # main
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # v1.0.4
# with:
# node-version: "24"

View file

@ -50,7 +50,11 @@ jobs:
name: ci / ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run policy unit tests
run: node --test test/pr-policy.test.mjs
shell: bash
- name: Install actionlint
env:

View file

@ -32,7 +32,7 @@ name: Release — Tag and GitHub Release
# Caller example:
# jobs:
# release:
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # main
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # v1.0.4
# with:
# version: ${{ inputs.version }}
#

106
README.md
View file

@ -2,6 +2,28 @@
Organization-level GitHub configuration for Sea Haven Industries.
## Git and PR conventions
### Branch naming
`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description. Branch names do not contain Jira keys.
### Commit format
`type(scope): description` — lowercase, imperative, no trailing period, header ≤ 72 chars. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`, `release`. Breaking change: `feat!:` + `BREAKING CHANGE:` footer.
### PR title
`type(scope): description (DEV-123)` — the Jira key is required at the end in parentheses. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive. Jira-exempt only: Dependabot PRs and permission-controlled emergency reverts.
### PR body
Exactly four headings in order: `## Summary`, `## Validation`, `## Tests`, `## Notes`. Use `None.` under Notes if empty.
### Deploy path
The two sanctioned deploy paths are merge to `main` triggering the pipeline and `workflow_dispatch` on that same pipeline. No manual workstation deploys to production.
## What's in here
### Reusable Workflows
@ -28,6 +50,12 @@ Organization-level GitHub configuration for Sea Haven Industries.
**`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping.
**`.github/workflows/callable-pr-policy.yaml`** — Reusable PR metadata gate. Validates PR title convention (type/scope/Jira key), branch naming, four-section body, commit subjects, AI attribution footers, and workflow file pin compliance — all via GitHub API, no checkout. Emits `policy / pr` when the caller job is named `policy`. Optional secrets `JIRA_CLOUD_ID`, `JIRA_SERVICE_ACCOUNT_EMAIL`, and `JIRA_API_TOKEN` must all be set for human PRs; Dependabot skips Jira/branch/body but still runs commit and workflow supply-chain checks. Emergency `revert` PRs may skip Jira with the `emergency-revert` label applied by a human collaborator with `maintain` or `admin` permission.
The supply-chain check operates in **diff mode**: for modified or renamed workflow files, the gate fetches the base-branch version at `pr.base.sha` and reports only violations whose normalized fingerprint is absent from the base. Added files must be fully compliant. Historical drift already present in the base branch is handled by the drift audit/remediation backlog, not by this gate. A failure to fetch the base version is a `POLICY-INFRA` error and the file is not silently grandfathered.
> **Supply-chain scanner.** Changed workflow files and action manifests (`action.yml` / `action.yaml` at any path) are scanned. Workflow files must use block-style structural keys and inline `run:`/`uses:` values. Action manifests follow the same constraints except that top-level `permissions:` is not required (action manifests do not support it). The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), flow-style `steps` arrays (`steps: [...]` with any content — `steps: []` is allowed), sequence-item anchor declarations (`- &anchor { uses: ... }` and the multiline form `- &anchor`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`), and local action references (`uses: ./...` — the scanner cannot recursively validate action manifests; inline the logic or replace with an immutable remote SHA pin). `docker://` action refs must carry an immutable sha256 digest pin (`docker://<image>@sha256:<64 lowercase hex>`); mutable tags and bare image names are rejected. Use literal unquoted key forms and inline values in all workflow steps.
**`.github/workflows/callable-labeler.yaml`** — Org-wide PR auto-labeler. Label rules live inline here (single source of truth) — consumer repos need only a thin caller with `contents: read`, `pull-requests: write`, and `issues: write`; no per-repo labeler.yml.
**`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph.
@ -36,6 +64,8 @@ Organization-level GitHub configuration for Sea Haven Industries.
**`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below).
**`.github/workflows/policy.yaml`** — Intentionally absent from this PR. The self-caller must pin `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` to a released 40-char SHA with a matching `# vX.Y.Z` comment; a mutable local `./` path reference is rejected by the supply-chain gate on modified workflow files. The follow-up PR can be opened once this PR merges and `release-on-merge.yaml` cuts the first release containing `callable-pr-policy.yaml`, then using `gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha` to obtain the pin.
**`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs.
**`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted.
@ -44,6 +74,8 @@ Organization-level GitHub configuration for Sea Haven Industries.
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
A `pr-policy` starter template can be added to `workflow-templates/` only after the PR that introduces `callable-pr-policy.yaml` merges and `release-on-merge.yaml` cuts the first release containing it. Until then, consumer repos must add the caller workflow manually (see §3).
### Ref pinning policy
All workflow refs across the org are pinned to full commit SHAs:
@ -54,8 +86,8 @@ All workflow refs across the org are pinned to full commit SHAs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # v1.0.3
```
Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the current tip of `main` (`gh api /repos/Sea-Haven-Industries/.github/commits/main --jq .sha`) and let Dependabot advance it from there.
- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) are likewise **SHA-pinned** with a trailing version comment (e.g. `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`); Dependabot keeps the SHA and comment current.
Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the latest release commit (`gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha`), annotate it with `# vX.Y.Z`, and let Dependabot advance it from there.
- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) — a subset are already SHA-pinned (e.g. `actions/labeler`, `aws-actions/*`, `docker/setup-qemu-action`, `ruby/setup-ruby`); the remainder (`actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-dotnet`, `actions/dependency-review-action`) currently use floating major-version tags. Full SHA pinning for this group is deferred (PLAT backlog); Dependabot will keep SHA and comment current once pins are set.
- **Binary installs are checksum-verified** (actionlint in `ci.yaml`).
### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`)
@ -110,7 +142,7 @@ A function's effective permissions are the **intersection** of its own role poli
2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it).
3. *Then* tighten the exec role.
Wrong order breaks every SAM deploy. (History: INFRA-103 established the boundary, INFRA-97 scoped the role.) CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role.
Wrong order breaks every SAM deploy. CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role.
This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role.
@ -135,6 +167,14 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3).
Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo:
| Secret | Value | Consumed by |
|--------|-------|-------------|
| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` |
| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` |
| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` |
### 2. Add CI to a repo
Create `.github/workflows/ci.yaml` in the target repo. Examples:
@ -149,7 +189,7 @@ on:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
```
**TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot):
@ -162,7 +202,7 @@ on:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
```
**Node.js SAM repo** (e.g., payments-dashboard):
@ -175,7 +215,7 @@ on:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
run-typecheck: false
run-cdk-synth: false
@ -192,15 +232,53 @@ on:
jobs:
python:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
source-dirs: "src"
run-sam-validate: false
typescript:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
```
### 3. Add CD to a repo
### 3. Add PR policy to a repo
Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1).
```yaml
name: PR Policy
on:
pull_request:
types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review]
concurrency:
group: policy-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
issues: read
pull-requests: read
jobs:
policy:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<full-commit-sha> # vX.Y.Z
secrets:
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
```
Replace `<full-commit-sha>` with the SHA of the release that contains `callable-pr-policy.yaml`:
```bash
gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha
```
The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes.
> **Known platform limitation — GITHUB_TOKEN label and metadata events.** When the `policy` workflow re-runs on `labeled` or `edited` events, the metadata edits themselves (label adds, title edits) must be performed by a GitHub App or a PAT that owns its own event stream. Edits made through `GITHUB_TOKEN` do not reliably emit a new `pull_request` event to trigger re-evaluation; the check stays in its prior state until the next push or manual re-run. Org automation that applies labels (such as the `emergency-revert` label) must therefore use a GitHub App token or a PAT — not `GITHUB_TOKEN` — or the policy gate will not re-run automatically after the label is applied. This is a GitHub platform constraint, not a deficiency that can be solved at the workflow level.
### 4. Add CD to a repo
Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
@ -214,7 +292,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
stack-name: afterhours-shift-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
@ -234,7 +312,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
```
@ -249,7 +327,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
python-version: "3.12"
cdk-dir: cdk
@ -267,7 +345,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
enable-qemu: true
secrets:
@ -284,7 +362,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # <release>
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj
eb-application: shoc-backend

View file

@ -4,7 +4,8 @@ Sea-Haven-Industries repositories are private and intended for internal Sea Have
## Where to go
- **Bugs, feature requests, infrastructure work** — file a ticket in Jira (**INFRA** project) or open an issue on the relevant repository.
- **Bugs and feature requests** — file a ticket in Jira (**DEV**, **PLAT**, or **SEC** depending on scope). GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe (contractor/fork intake).
- **Infrastructure and platform work** — use the **PLAT** project. Security issues go in **SEC**.
- **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com).
- **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook).
- **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue).

2418
test/pr-policy.test.mjs Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
# reusable workflow's default — passed explicitly to pin against drift.

View file

@ -11,4 +11,4 @@ jobs:
# Every input is optional. Common overrides: `solution` (defaults to *.sln
# in the working directory), `working-directory`, and `dotnet-version`
# (defaults to 8.0.x). This reusable has no `node-version` input.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4

View file

@ -7,7 +7,7 @@ jobs:
ci:
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
# check context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also
# the reusable workflow's default — passed explicitly to pin against drift.

View file

@ -5,7 +5,7 @@ on:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
# reusable workflow's default — passed explicitly to pin against drift.

View file

@ -11,4 +11,4 @@ jobs:
# Every input is optional. Common overrides: `source-dirs` (ruff targets),
# `requirements` (non-default requirements file), `subproject-dir` (a
# self-contained suite that must run in its own working directory).
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4

View file

@ -5,7 +5,7 @@ on:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
run-tests: true
# ci-python-sam.yaml declares a `node-version` input (default "24") that

View file

@ -7,7 +7,7 @@ jobs:
ci:
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
# context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift.

View file

@ -7,7 +7,7 @@ jobs:
ci:
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
# context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift.

View file

@ -8,4 +8,4 @@ permissions:
jobs:
dependency-review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4

View file

@ -5,7 +5,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Required: the project to publish, relative to the repo root.
project: REPLACE-ME-project-csproj

View file

@ -13,4 +13,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4

View file

@ -5,7 +5,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift.

View file

@ -13,7 +13,7 @@ permissions:
jobs:
release:
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
version: ${{ inputs.version }}
# Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default

View file

@ -5,7 +5,7 @@ on:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
# Required: the CloudFormation stack name (kebab-case, matches repo name).
# NOTE: this is a literal placeholder on purpose — starter-workflow variables