Merge pull request #2031 from pikasTech/feat/selfmedia-production-release
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat: 建立 SELFMEDIA release 生产交付 lane
This commit is contained in:
Lyon
2026-07-14 19:39:11 +08:00
committed by GitHub
10 changed files with 325 additions and 8 deletions
+46
View File
@@ -400,6 +400,52 @@ sourceAuthority:
naming: gitea-actions-immutable-source
prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01
legacyGitMirror: null
- key: selfmedia-production-nc01
targetId: NC01
credentialOverride:
github:
transport: https-token
sourceRef: pikainc-selfmedia-gh-token.txt
sourceKey: GH_TOKEN
format: raw-token
requiredFor:
- upstream-mirror
- mirror-sync
- managed-repository-fetch
- github-head-observe
- github-hooks-list
- github-hooks-reconcile
permissions:
contents: read
metadata: read
webhooks: read-write
gitFetchCredential:
apiVersion: unidesk.ai/v1
kind: GitFetchCredential
authMode: github-https-token
host: github.com
secretRef:
namespace: devops-infra
name: gitea-github-sync-secrets
key: github-token-pikainc-selfmedia-release
upstream:
repository: pikainc/selfmedia
cloneUrl: https://github.com/pikainc/selfmedia.git
branch: release
visibility: private
gitea:
owner: mirrors
name: pikainc-selfmedia
mirrorMode: controlled-push
publicRead: false
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
gitops:
branch: nc01-selfmedia-production-gitops
flushDisposition: gitea-writeback
snapshot:
naming: gitea-actions-immutable-source
prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01
legacyGitMirror: null
- key: pikaoa-nc01
targetId: NC01
credentialOverride:
@@ -211,6 +211,41 @@ repositories:
runtime_service_port: "4317"
health_path: /healthz
health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz
- id: selfmedia-production-nc01
name: selfmedia-production-nc01
namespace: selfmedia-production-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikainc-selfmedia
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
owner: mirrors
repo: pikainc-selfmedia
secretName: pac-gitea-selfmedia-production-nc01
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
params:
git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
source_branch: release
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01
node: NC01
pipeline_name: selfmedia-production-nc01-pac
pipeline_run_prefix: selfmedia-production-nc01
service_account: selfmedia-production-nc01-tekton-runner
pipeline_timeout: 2h0m0s
image_repository: 127.0.0.1:5000/selfmedia/newsroom-production
registry_probe_base: http://127.0.0.1:5000
gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
gitops_branch: nc01-selfmedia-production-gitops
gitops_username: unidesk-admin
gitops_secret_name: pac-gitea-selfmedia-production-nc01
gitops_manifest_path: deploy/gitops/nc01-production/resources.yaml
runtime_namespace: selfmedia-production
runtime_deployment: selfmedia-production
runtime_service: selfmedia-production
runtime_service_port: "4317"
health_path: /healthz
health_url: http://selfmedia-production.selfmedia-production.svc.cluster.local:4317/healthz
- id: pikaoa-nc01
name: pikaoa-nc01
namespace: pikaoa-ci
@@ -411,6 +446,47 @@ consumers:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
- id: selfmedia-production-nc01
repositoryRef: selfmedia-production-nc01
node: NC01
lane: selfmedia-production
namespace: selfmedia-production-ci
pipeline: selfmedia-production-nc01-pac
pipelineRunPrefix: selfmedia-production-nc01
argoNamespace: argocd
argoApplication: selfmedia-production-nc01
closeoutGitOpsMirrorFlush: false
argoBootstrap:
project: default
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
targetRevision: nc01-selfmedia-production-gitops
path: deploy/gitops/nc01-production
destinationNamespace: selfmedia-production
automated: true
repositoryCredential:
secretName: argocd-repo-selfmedia-production-nc01
username: unidesk-admin
deliveryProvenance:
required: true
markerValue: admission-pac-v2:selfmedia-production-nc01
executionServiceAccountName: selfmedia-production-nc01-tekton-runner
gitOps:
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
targetRevision: nc01-selfmedia-production-gitops
runnerServiceAccount:
name: selfmedia-production-nc01-tekton-runner
automountServiceAccountToken: false
roleBindingName: selfmedia-production-nc01-tekton-runner
sourceArtifact:
mode: embedded-pipeline-spec
renderer: selfmedia-runtime
configRef: config/selfmedia.yaml#delivery.targets.NC01_PRODUCTION
pipelineRunPath: .tekton/selfmedia-production-nc01-pac.yaml
maxKeepRuns: 8
taskRunTemplate:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
- id: pikaoa-nc01
repositoryRef: pikaoa-nc01
node: NC01
+46
View File
@@ -219,6 +219,16 @@ targets:
- selfmedia
timeoutSeconds: 180
pollIntervalSeconds: 5
- id: selfmedia-production-nc01
route: NC01:k3s
namespace: selfmedia-production
scope: selfmedia-production
enabled: true
consumerRollout:
deployments:
- selfmedia-production
timeoutSeconds: 180
pollIntervalSeconds: 5
- id: pikaoa-nc01
route: NC01:k3s
namespace: pikaoa
@@ -282,6 +292,42 @@ kubernetesSecrets:
- sourceRef: ~/.codex/auth.json.pika
sourceKey: contents
targetKey: auth.json
- name: selfmedia-production-access
targetId: selfmedia-production-nc01
secretName: selfmedia-production-access
type: Opaque
data:
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_ADMIN_USERNAME
targetKey: admin-username
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_ADMIN_PASSWORD
targetKey: admin-password
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_API_KEY
targetKey: api-key
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_SESSION_SECRET
targetKey: session-secret
- name: selfmedia-production-runtime
targetId: selfmedia-production-nc01
secretName: selfmedia-production-runtime
type: Opaque
data:
- sourceRef: ~/.env/TOKEN
sourceKey: contents
targetKey: TOKEN
- name: selfmedia-production-codex
targetId: selfmedia-production-nc01
secretName: selfmedia-production-codex
type: Opaque
data:
- sourceRef: ~/.codex/config.toml.pika
sourceKey: contents
targetKey: config.toml
- sourceRef: ~/.codex/auth.json.pika
sourceKey: contents
targetKey: auth.json
- name: sub2rank-runtime
targetId: sub2rank-nc01
secretName: sub2rank-secrets
+58
View File
@@ -238,6 +238,64 @@ delivery:
- --last
sourcePath: /app
skillsPath: /home/codex/.codex/skills
NC01_PRODUCTION:
extends: NC01
node: NC01
lane: selfmedia-production
ci:
namespace: selfmedia-production-ci
pipeline: selfmedia-production-nc01-pac
pipelineRunPrefix: selfmedia-production-nc01
serviceAccountName: selfmedia-production-nc01-tekton-runner
roleBindingName: selfmedia-production-nc01-tekton-runner
source:
branch: release
snapshotPrefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01
build:
imageRepository: 127.0.0.1:5000/selfmedia/newsroom-production
envReuse:
statePath: /var/lib/unidesk/selfmedia-production-ci/buildkit-state
gitops:
branch: nc01-selfmedia-production-gitops
manifestPath: deploy/gitops/nc01-production/resources.yaml
releaseStatePath: deploy/gitops-state/nc01-production/release.json
credentialSecretName: pac-gitea-selfmedia-production-nc01
author:
name: SelfMedia NC01 Production CI
email: selfmedia-production-nc01-ci@unidesk.local
deployment:
metadata:
name: selfmedia-production-nc01
target:
id: NC01_PRODUCTION
namespace: selfmedia-production
runtime:
serviceAccountName: selfmedia-production
service:
name: selfmedia-production
publicExposure:
address: http://152.53.229.148:4319
hostPort: 4319
edge:
name: selfmedia-production-public-edge
upstream: selfmedia-production.selfmedia-production.svc.cluster.local:4317
storage:
media:
claimName: selfmedia-production-data
codexSessions:
claimName: selfmedia-production-codex-sessions
secrets:
access:
target:
secretName: selfmedia-production-access
runtime:
target:
secretName: selfmedia-production-runtime
codex:
target:
secretName: selfmedia-production-codex
codex:
supervisorStatePath: /home/codex/.codex/state/selfmedia-production-supervisor.json
cutover:
targets:
NC01:
@@ -0,0 +1,24 @@
# R3 任务报告
## 当前交付检查点
- 已在 `config/selfmedia.yaml` 声明 `NC01_PRODUCTION` 生产 delivery target,并以显式 `extends` 复用开发基线、覆盖全部生产可写状态。
- 已增加 `selfmedia-production-nc01` Repository/consumer,精确跟踪 `pikainc/selfmedia@release`,使用独立 CI namespace、Pipeline、snapshot、镜像仓、GitOps branch/Application 与运行 namespace。
- 已声明独立 `selfmedia-production` Service、PVC、Secret、NetworkPolicy 和公网 `152.53.229.148:4319`;开发 `master` consumer 与 `4317` 入口保持不变。
- bootstrap YAML 精确匹配加入 source branch,避免同仓库多分支 consumer 歧义。
- 删除 `config/unidesk-cli.yaml` 中一项完全重复的 `pikainc/pikaoa` repository override,恢复受控 GitHub CLI fail-closed token 选择;未修改任何 token 内容。
- 已通过 source-artifact renderer 在 `pikainc/selfmedia` release 专用分支生成 `.tekton/selfmedia-production-nc01-pac.yaml`PR 为 https://github.com/pikainc/selfmedia/pull/29。
## 已完成验证
- `bun test scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts scripts/src/platform-infra-gitea-config.test.ts`16 项通过。
- `bun scripts/cli.ts check --syntax-only`11 项通过。
- `source-artifact check`:生产 PipelineSpec、provenance 与 owning YAML 对齐,source worktree clean。
- `pipelines-as-code bootstrap --dry-run`:release 精确匹配,四阶段均为 plannedmutation=false。
- `gh auth status --repo pikainc/selfmedia`repository override、REST 与仓库访问正常,未披露凭据。
## 待主代理审核后的自动链验收
- 合并 UniDesk PR 后执行一次首发 `bootstrap --confirm`,只安装 YAML 声明的控制面,不同步源码、不创建人工 PipelineRun。
- 审核并合并 `pikainc/selfmedia#29` 到 release,由正常 PR merge 自动驱动 webhook、Gitea snapshot、PaC、Tekton、GitOps/Argo 与 runtime。
- 使用受控 status/source-artifact verify-runtime 验证生产 digest、health、独立公网端口与登录保护后完成 R3。
@@ -26,19 +26,30 @@ test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => {
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: `${mirror.gitea.readUrl}/`,
sourceBranch: "main",
});
expect(selected.key).toBe("widgets-nc01");
const releaseMirror = { ...mirror, key: "widgets-release-nc01", upstream: { ...mirror.upstream, branch: "release" } };
expect(resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, releaseMirror]), "NC01", {
id: "widgets-release-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "release",
}).key).toBe("widgets-release-nc01");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "main",
})).toThrow("no exact match");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, { ...mirror }]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "main",
})).toThrow("2 exact matches");
});
@@ -6,6 +6,7 @@ export interface PacBootstrapRepositoryIdentity {
readonly owner: string;
readonly repo: string;
readonly cloneUrl: string;
readonly sourceBranch: string;
}
export function resolvePacBootstrapMirrorRepository(
@@ -18,12 +19,13 @@ export function resolvePacBootstrapMirrorRepository(
&& item.gitea.owner === repository.owner
&& item.gitea.name === repository.repo
&& repositoryUrlIdentity(item.gitea.readUrl) === repositoryUrlIdentity(repository.cloneUrl)
&& item.upstream.branch === repository.sourceBranch
));
if (candidates.length !== 1) {
const disposition = candidates.length === 0 ? "no exact match" : `${candidates.length} exact matches`;
throw new Error(
`cannot resolve the YAML-owned Gitea bootstrap repository for PaC repository ${repository.id} on ${targetId}: ${disposition}; `
+ "match targetId, gitea owner/name, and read URL in config/platform-infra/gitea.yaml",
+ "match targetId, gitea owner/name, read URL, and source branch in config/platform-infra/gitea.yaml",
);
}
return candidates[0];
@@ -989,7 +989,10 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<
const repository = resolveRepository(pac, consumer.repositoryRef);
let mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>;
try {
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, {
...repository,
sourceBranch: repository.params.source_branch,
});
} catch (error) {
return pacBootstrapYamlMatchFailure(target.id, consumer.id, error);
}
+43 -4
View File
@@ -100,7 +100,8 @@ export function readSelfMediaConfig(): SelfMediaConfig {
const cutover = record(root.cutover, "cutover");
const deliveryRecords = record(delivery.targets, "delivery.targets");
const cutoverRecords = record(cutover.targets, "cutover.targets");
const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))]));
const resolvedDeliveryRecords = resolveDeliveryRecords(deliveryRecords);
const deliveryTargets = Object.fromEntries(Object.entries(resolvedDeliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, value)]));
const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))]));
const defaultTargetId = text(defaults.targetId, "defaults.targetId");
if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) {
@@ -117,6 +118,12 @@ export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMe
return target;
}
export function resolveSelfMediaDeliveryTargetByConfigRef(configRef: string): SelfMediaDeliveryTarget {
const prefix = `${selfMediaConfigRef}#delivery.targets.`;
if (!configRef.startsWith(prefix)) throw new Error(`selfmedia delivery configRef must start with ${prefix}`);
return resolveSelfMediaDeliveryTarget(configRef.slice(prefix.length));
}
export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget {
const config = readSelfMediaConfig();
const id = targetId ?? config.defaultTargetId;
@@ -158,13 +165,13 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`);
const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`);
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`);
if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`);
const node = text(value.node, `${path}.node`);
if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`);
if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`);
if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`);
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) <= 0) throw new Error(`${path}.deployment.publicExposure.hostPort must be positive`);
validateAccessSecret(deployment, `${path}.deployment`);
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
@@ -176,7 +183,7 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
if (identityFiles.length === 0 || new Set(identityFiles).size !== identityFiles.length) throw new Error(`${path}.build.envReuse.identityFiles must be non-empty and unique`);
return {
id,
node: id,
node,
lane: text(value.lane, `${path}.lane`),
route: text(value.route, `${path}.route`),
ci: {
@@ -226,6 +233,38 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
};
}
function resolveDeliveryRecords(records: Record<string, unknown>): Record<string, Record<string, unknown>> {
const resolved: Record<string, Record<string, unknown>> = {};
const resolving = new Set<string>();
const resolveOne = (id: string): Record<string, unknown> => {
if (resolved[id] !== undefined) return resolved[id];
if (resolving.has(id)) throw new Error(`${selfMediaConfigRef}.delivery.targets.${id}.extends contains a cycle`);
const raw = record(records[id], `delivery.targets.${id}`);
resolving.add(id);
const parentId = raw.extends === undefined ? null : text(raw.extends, `delivery.targets.${id}.extends`);
const own = Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "extends"));
const effective = parentId === null ? structuredClone(own) : deepMerge(resolveOne(parentId), own);
resolving.delete(id);
resolved[id] = effective;
return effective;
};
for (const id of Object.keys(records)) resolveOne(id);
return resolved;
}
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const result = structuredClone(base);
for (const [key, value] of Object.entries(override)) {
const current = result[key];
result[key] = isPlainRecord(current) && isPlainRecord(value) ? deepMerge(current, value) : structuredClone(value);
}
return result;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
const secrets = record(deployment.secrets, `${path}.secrets`);
const access = record(secrets.access, `${path}.secrets.access`);
+14 -2
View File
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
import {
resolveSelfMediaDeliveryTarget,
resolveSelfMediaDeliveryTargetByConfigRef,
selfMediaDeliveryConfigRef,
selfMediaEffectiveDeployment,
type SelfMediaDeliveryTarget,
@@ -39,7 +40,7 @@ export interface SelfMediaRenderedPipeline {
}
export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline {
const target = resolveSelfMediaDeliveryTarget(binding.consumer.node);
const target = resolveSelfMediaDeliveryTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
const configRef = selfMediaDeliveryConfigRef(target.id);
if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
throw new Error("selfmedia-runtime requires embedded-pipeline-spec");
@@ -373,7 +374,8 @@ function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Reco
throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`);
}
const output = result.stdout;
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"];
const publicExposure = requiredRecord(effectiveDeployment.publicExposure, "deployment.publicExposure");
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", `hostPort: ${requiredInteger(publicExposure.hostPort, "deployment.publicExposure.hostPort")}`, "automountServiceAccountToken: false"];
for (const marker of required) {
if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`);
}
@@ -408,6 +410,16 @@ function requiredStringArray(value: unknown, path: string): string[] {
return value as string[];
}
function requiredRecord(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function requiredInteger(value: unknown, path: string): number {
if (!Number.isInteger(value)) throw new Error(`${path} must be an integer`);
return value as number;
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}