feat(pages): 支持 GitHub Release 部署源

增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
deqiying
2026-07-19 18:31:42 +08:00
parent 38b0516937
commit c39a3edcc3
34 changed files with 5751 additions and 278 deletions
+59 -47
View File
@@ -5,51 +5,63 @@
package pages
const (
errPagesProjectNotFound = "pages 项目不存在"
errPagesSlugExists = "pages 项目标识已存在"
errPagesNameRequired = "pages 项目名称不能为空"
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "pages 部署不存在"
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageURLRequired = "请填写部署包下载链接"
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "pages 部署包路径为空"
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
errPagesActorMissing = "无法识别当前用户"
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
errPagesSourceNotFound = "pages 部署源不存在"
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
errPagesSourceTypeUnsupported = "当前阶段仅支持远程地址部署源"
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
errPagesSourceActionBusy = "pages 部署源任务正在执行"
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
errPagesSourceSyncFailed = "pages 部署源同步失败"
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
errPagesProjectNotFound = "pages 项目不存在"
errPagesSlugExists = "pages 项目标识已存在"
errPagesNameRequired = "pages 项目名称不能为空"
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "pages 部署不存在"
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageURLRequired = "请填写部署包下载链接"
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "pages 部署包路径为空"
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
errPagesActorMissing = "无法识别当前用户"
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
errPagesSourceNotFound = "pages 部署源不存在"
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
errPagesSourceActionBusy = "pages 部署源任务正在执行"
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
errPagesSourceSyncFailed = "pages 部署源同步失败"
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
)
@@ -0,0 +1,343 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"net/url"
"path"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
githubReleaseSelectorLatest = "latest"
githubReleaseSelectorTag = "tag"
githubSourceIdentityDomain = "openflare:pages:github-release:v2"
initialCheckRetryDelay = 5 * time.Minute
githubRepositoryPathParts = 2
githubCheckJitterRange = 301
githubCheckJitterCenter = 150
)
var (
githubOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type githubSourceConfig struct {
Repository string
Selector string
Tag string
AssetName string
CheckInterval int
SourceIdentity string
}
func validateGitHubSourceInput(input SourceUpdateInput) error {
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
return errors.New(errPagesSourceTypeUnsupported)
}
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
return errors.New(errPagesSourceGitHubFields)
}
if input.AutoUpdateEnabled {
return errors.New(errPagesSourceAutoNotAvailable)
}
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
return err
}
selector := strings.TrimSpace(input.ReleaseSelector)
if selector == "" {
selector = githubReleaseSelectorLatest
}
assetName := input.AssetName
if assetName == "" {
assetName = defaultGitHubAssetName
}
if !validGitHubAssetName(assetName) {
return errors.New(errPagesSourceAssetNameInvalid)
}
switch selector {
case githubReleaseSelectorLatest:
if input.ReleaseTag != "" {
return errors.New(errPagesSourceSelectorInvalid)
}
interval := input.CheckIntervalMinutes
if interval != 0 && (interval < minimumCheckInterval || interval > maximumCheckInterval) {
return errors.New(errPagesSourceCheckInterval)
}
case githubReleaseSelectorTag:
if !validGitHubReleaseTagConfig(input.ReleaseTag) || input.CheckIntervalMinutes != 0 {
return errors.New(errPagesSourceSelectorInvalid)
}
default:
return errors.New(errPagesSourceSelectorInvalid)
}
return nil
}
func buildGitHubSourceConfig(input SourceUpdateInput) (githubSourceConfig, error) {
repository, err := normalizeGitHubRepositoryURL(input.RepositoryURL)
if err != nil {
return githubSourceConfig{}, err
}
selector := strings.TrimSpace(input.ReleaseSelector)
if selector == "" {
selector = githubReleaseSelectorLatest
}
tag := input.ReleaseTag
assetName := input.AssetName
if assetName == "" {
assetName = defaultGitHubAssetName
}
interval := input.CheckIntervalMinutes
if selector == githubReleaseSelectorLatest && interval == 0 {
interval = defaultCheckInterval
}
return githubSourceConfig{
Repository: repository,
Selector: selector,
Tag: tag,
AssetName: assetName,
CheckInterval: interval,
SourceIdentity: buildGitHubSourceIdentity(repository, selector, tag, assetName),
}, nil
}
func buildGitHubSourceIdentity(repository, selector, tag, assetName string) string {
fields := [...]string{repository, selector, tag, assetName}
encoded := make([]byte, 0, len(githubSourceIdentityDomain)+len(fields)*8+
len(repository)+len(selector)+len(tag)+len(assetName))
encoded = append(encoded, githubSourceIdentityDomain...)
var fieldLength [8]byte
for _, field := range fields {
// Go strings hold the validated UTF-8 bytes used by GitHub. Prefixing each
// field with its byte length prevents delimiter characters from creating
// ambiguous identities across field boundaries.
binary.BigEndian.PutUint64(fieldLength[:], uint64(len(field)))
encoded = append(encoded, fieldLength[:]...)
encoded = append(encoded, field...)
}
identityHash := sha256.Sum256(encoded)
return hex.EncodeToString(identityHash[:])
}
func normalizeGitHubRepositoryURL(raw string) (string, error) {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
strings.Contains(raw, "#") ||
parsed.EscapedPath() != parsed.Path || !strings.HasPrefix(parsed.Path, "/") ||
strings.HasPrefix(parsed.Path, "//") || strings.HasSuffix(parsed.Path, "/") {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
parts := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
if len(parts) != githubRepositoryPathParts {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
owner := parts[0]
repository := parts[1]
repository = strings.TrimSuffix(repository, ".git")
if !githubOwnerPattern.MatchString(owner) || !githubRepoPattern.MatchString(repository) ||
len(repository) > 100 || repository == "." || repository == ".." {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
return owner + "/" + repository, nil
}
func validGitHubReleaseTagConfig(value string) bool {
if !validGitHubReleaseDisplayTag(value) ||
strings.ContainsAny(value, " ~^:?*[\\") || strings.HasPrefix(value, "/") ||
strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".") ||
strings.Contains(value, "//") || strings.Contains(value, "..") || strings.Contains(value, "@{") {
return false
}
for component := range strings.SplitSeq(value, "/") {
if strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
return false
}
}
return true
}
func validGitHubReleaseDisplayTag(value string) bool {
if value == "" || len(value) > 255 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if unsafeGitHubInputRune(character) {
return false
}
}
return true
}
func validGitHubAssetName(value string) bool {
if value == "" || len(value) > 255 || !utf8.ValidString(value) ||
path.Base(value) != value || strings.Contains(value, "\\") ||
value == "." || value == ".." {
return false
}
for _, character := range value {
if unsafeGitHubInputRune(character) {
return false
}
}
return true
}
func unsafeGitHubInputRune(character rune) bool {
return unicode.IsControl(character) || character == '\u2028' || character == '\u2029' ||
character == '\u061c' || character == '\u200e' || character == '\u200f' ||
(character >= '\u202a' && character <= '\u202e') ||
(character >= '\u2066' && character <= '\u2069')
}
func updateGitHubSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
var project model.PagesProject
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
return false, err
}
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
if err != nil {
return false, err
}
config, err := buildGitHubSourceConfig(input)
if err != nil {
return false, err
}
if !hasExisting {
return true, createGitHubSourceTx(tx, projectID, config)
}
if !githubSourceConfigChanged(existing, config) {
return false, nil
}
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", existing.ID).First(&runtime).Error; err != nil {
return false, err
}
identityChanged := existing.SourceIdentity != config.SourceIdentity
if err := tx.Model(existing).Updates(githubSourceUpdates(config, existing.ConfigVersion+1)).Error; err != nil {
return false, err
}
if err := resetRuntimeAfterGitHubUpdate(tx, &runtime, config, identityChanged); err != nil {
return false, err
}
return true, nil
}
func createGitHubSourceTx(tx *gorm.DB, projectID uint, config githubSourceConfig) error {
source := &model.PagesProjectSource{
ProjectID: projectID,
SourceType: PagesSourceTypeGitHubRelease,
GitHubRepository: config.Repository,
ReleaseSelector: config.Selector,
ReleaseTag: config.Tag,
AssetName: config.AssetName,
AutoUpdateEnabled: false,
CheckIntervalMinutes: config.CheckInterval,
ConfigVersion: 1,
SourceIdentity: config.SourceIdentity,
}
if err := tx.Create(source).Error; err != nil {
return err
}
runtime := &model.PagesProjectSourceRuntime{SourceID: source.ID, SyncStatus: pagesSourceStatusIdle}
if config.Selector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(time.Now(), source.ID, config.CheckInterval)
runtime.NextCheckAt = &next
}
return tx.Create(runtime).Error
}
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
return map[string]any{
"source_type": PagesSourceTypeGitHubRelease,
"remote_url": "",
"remote_network_policy": "",
"github_repository": config.Repository,
"release_selector": config.Selector,
"release_tag": config.Tag,
"asset_name": config.AssetName,
sourceColumnAutoUpdateEnabled: false,
"check_interval_minutes": config.CheckInterval,
sourceColumnConfigVersion: version,
"source_identity": config.SourceIdentity,
}
}
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled ||
existing.CheckIntervalMinutes != config.CheckInterval
}
func resetRuntimeAfterGitHubUpdate(
tx *gorm.DB,
runtime *model.PagesProjectSourceRuntime,
config githubSourceConfig,
identityChanged bool,
) error {
if err := resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged); err != nil {
return err
}
var nextCheckAt any
if config.Selector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(time.Now(), runtime.SourceID, config.CheckInterval)
nextCheckAt = &next
}
return tx.Model(runtime).Update("next_check_at", nextCheckAt).Error
}
func nextGitHubCheckAt(now time.Time, sourceID uint, intervalMinutes int) time.Time {
// A stable, bounded offset avoids a thundering herd without persisting
// another scheduling field. Scanner Phase 3 reuses this calculation.
jitterSeconds := int64(sourceID%githubCheckJitterRange) - githubCheckJitterCenter
return now.Add(time.Duration(intervalMinutes)*time.Minute + time.Duration(jitterSeconds)*time.Second)
}
func markInitialCheckDispatchFailed(ctx context.Context, sourceID uint, configVersion int) {
updates := map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: errPagesSourceInitialCheckWarning,
}
var source model.PagesProjectSource
if err := db.DB(ctx).Where("id = ? AND config_version = ?", sourceID, configVersion).First(&source).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "[PagesSource] load initial check source snapshot failed: source_id=%d error=%v", sourceID, err)
}
return
}
if source.ReleaseSelector == githubReleaseSelectorLatest {
next := time.Now().Add(initialCheckRetryDelay)
updates["next_check_at"] = &next
}
now := time.Now()
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", sourceID).
Where("lease_expires_at IS NULL OR lease_expires_at <= ?", now).
Where("EXISTS (SELECT 1 FROM of_pages_project_sources source WHERE source.id = ? AND source.config_version = ?)", sourceID, configVersion).
Updates(updates)
if result.Error != nil {
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, result.Error)
}
}
@@ -0,0 +1,724 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const githubSourceDetailProvider = "github"
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
type githubSourceProviderDomainError struct {
message string
permanent bool
retryAt *time.Time
}
func (domainError *githubSourceProviderDomainError) Error() string {
return domainError.message
}
type githubReleaseAPI interface {
Resolve(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
Download(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
}
var newGitHubReleaseClient = func() githubReleaseAPI {
return githubrelease.NewClient()
}
type githubSourceTarget struct {
Revision string
Detail sourceDetail
DetailJSON string
Release githubrelease.Release
Asset githubrelease.Asset
RetryAt *time.Time
}
type githubCheckTaskResult struct {
Message string
Detail string
Stale bool
}
type preparedGitHubSource struct {
target *githubSourceTarget
download *githubrelease.DownloadResult
format pagesarchive.Format
manifest *deploymentManifest
ingestState *sourceIngestState
limits pagesLimits
}
func checkGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
) (*githubCheckTaskResult, error) {
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
client := newGitHubReleaseClient()
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
Tag: snapshot.ReleaseTag,
AssetName: snapshot.AssetName,
ETag: snapshot.ETag,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
retryAt, _ := githubrelease.RetryAt(err)
domainErr := githubSourceDomainError(err)
if failErr := failGitHubCheckLease(ctx, snapshot, domainErr.Error(), retryAt); failErr != nil {
if errors.Is(failErr, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, failErr
}
return nil, domainErr
}
if result.NotModified {
if err := finishGitHubCheckNotModified(ctx, snapshot, result); err != nil {
if errors.Is(err, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, err
}
return &githubCheckTaskResult{Message: "GitHub Release 检查完成,内容未变化"}, nil
}
target, err := buildGitHubSourceTarget(result.Release, result.Asset, result.RetryAt)
if err != nil {
retryAt := time.Time{}
if result.RetryAt != nil {
retryAt = result.RetryAt.UTC()
}
if failErr := failGitHubCheckLease(ctx, snapshot, err.Error(), retryAt); failErr != nil {
if errors.Is(failErr, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, failErr
}
return nil, err
}
status, err := finishGitHubCheckTarget(ctx, snapshot, result, target)
if err != nil {
if errors.Is(err, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, err
}
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
message := "GitHub Release 检查完成"
switch status {
case pagesSourceStatusUpdateAvailable:
message = "发现新的 GitHub Release 部署包"
case pagesSourceStatusAttention:
message = "检测到同一 Release 的资源被替换,需要确认"
}
return &githubCheckTaskResult{Message: message, Detail: string(detail)}, nil
}
func buildGitHubSourceTarget(
release githubrelease.Release,
asset githubrelease.Asset,
retryAt *time.Time,
) (*githubSourceTarget, error) {
digest := strings.ToLower(strings.TrimSpace(asset.Digest))
if digest != "" && !githubDigestPattern.MatchString(digest) {
return nil, errors.New(errPagesSourceDigestInvalid)
}
if strings.TrimSpace(release.ID) == "" || strings.TrimSpace(asset.ID) == "" ||
!validGitHubReleaseDisplayTag(release.Tag) || !validGitHubAssetName(asset.Name) ||
asset.State != "uploaded" || asset.UpdatedAt.IsZero() {
return nil, errors.New(errPagesSourceReleaseNotFound)
}
updatedAt := asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
rawRevision := "github:" + release.ID + ":" + asset.ID + ":" + updatedAt + ":" + digest
sum := sha256.Sum256([]byte(rawRevision))
detail := sourceDetail{
Provider: githubSourceDetailProvider,
Tag: release.Tag,
AssetName: asset.Name,
ReleaseID: release.ID,
AssetID: asset.ID,
AssetUpdatedAt: updatedAt,
Digest: digest,
}
detailJSON, err := json.Marshal(detail)
if err != nil {
return nil, errors.New(errPagesSourceSyncFailed)
}
return &githubSourceTarget{
Revision: hex.EncodeToString(sum[:]),
Detail: detail,
DetailJSON: string(detailJSON),
Release: release,
Asset: asset,
RetryAt: retryAt,
}, nil
}
func finishGitHubCheckNotModified(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
result githubrelease.ResolveResult,
) error {
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
if err != nil {
return err
}
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
updates["etag"] = result.ETag
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
return tx.Model(runtime).Updates(updates).Error
})
}
func finishGitHubCheckTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
result githubrelease.ResolveResult,
target *githubSourceTarget,
) (string, error) {
status := pagesSourceStatusIdle
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
if err != nil {
return err
}
status = targetRuntimeStatus(target, runtime.LastAppliedRevision, runtime.LastAppliedDetail)
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
updates["etag"] = result.ETag
updates["last_seen_revision"] = target.Revision
updates["last_seen_detail"] = target.DetailJSON
updates[sourceRuntimeColumnSyncStatus] = status
return tx.Model(runtime).Updates(updates).Error
})
return status, err
}
func githubCheckTerminalUpdates(
snapshot *sourceExecutionSnapshot,
now time.Time,
retryAt *time.Time,
) map[string]any {
updates := map[string]any{
sourceRuntimeColumnLastError: "",
sourceRuntimeColumnLastCheckedAt: &now,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
updates["next_check_at"] = nextCheckAfterGitHubResponse(snapshot, now, retryAt)
return updates
}
func nextCheckAfterGitHubResponse(
snapshot *sourceExecutionSnapshot,
now time.Time,
retryAt *time.Time,
) any {
if snapshot.ReleaseSelector != githubReleaseSelectorLatest {
return nil
}
next := nextGitHubCheckAt(now, snapshot.SourceID, snapshot.CheckIntervalMinutes)
if retryAt != nil && retryAt.After(next) {
next = retryAt.UTC()
}
return &next
}
func lockOwnedSourceRuntime(
tx *gorm.DB,
snapshot *sourceExecutionSnapshot,
) (*model.PagesProjectSourceRuntime, time.Time, error) {
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", snapshot.SourceID).First(&runtime).Error; err != nil {
return nil, time.Time{}, err
}
now := time.Now()
if runtime.LeaseToken != snapshot.LeaseToken || runtime.LeaseExpiresAt == nil ||
!runtime.LeaseExpiresAt.After(now) {
return nil, time.Time{}, errSourceFinalFence
}
return &runtime, now, nil
}
func failGitHubCheckLease(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
message string,
retryAt time.Time,
) error {
now := time.Now()
next := now.Add(initialCheckRetryDelay)
if retryAt.After(next) {
next = retryAt.UTC()
}
updates := map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
sourceRuntimeColumnLastCheckedAt: &now,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
updates["next_check_at"] = &next
} else {
updates["next_check_at"] = nil
}
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errSourceFinalFence
}
return nil
}
func targetRuntimeStatus(
target *githubSourceTarget,
appliedRevision string,
appliedDetail string,
) string {
if target == nil || target.Revision == appliedRevision {
return pagesSourceStatusIdle
}
applied := sourceDetail{}
if unmarshalSourceDetail(appliedDetail, &applied) == nil && target.Detail.ReleaseID != "" &&
target.Detail.ReleaseID == applied.ReleaseID {
return pagesSourceStatusAttention
}
return pagesSourceStatusUpdateAvailable
}
func preflightGitHubSyncConfirmation(ctx context.Context, sourceID uint, confirmedRevision string) error {
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
return err
}
replacement := sourceHasSameReleaseReplacement(&runtime)
if replacement && confirmedRevision == "" {
return errors.New(errPagesSourceConfirmationNeeded)
}
if confirmedRevision != "" && (!replacement || confirmedRevision != runtime.LastSeenRevision) {
return errors.New(errPagesSourceConfirmationStale)
}
return nil
}
func syncGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
actor string,
targetRevision string,
confirmedRevision string,
) (outcome *sourceSyncOutcome, resultErr error) {
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
return nil, errors.New(errPagesSourceActionInvalid)
}
defer func() {
resultErr = finalizeGitHubSyncFailure(ctx, snapshot, resultErr)
}()
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
ctx, snapshot, pagesSourceSyncLeaseDuration, pagesSourceHeartbeatInterval,
)
if err != nil {
return sourceHeartbeatOutcome(err)
}
defer func() { _ = heartbeat.stop() }()
client := newGitHubReleaseClient()
target, guardedOutcome, err := resolveAndGuardGitHubSync(
workCtx, client, snapshot, targetRevision, confirmedRevision,
)
if err != nil {
return nil, err
}
if guardedOutcome != nil {
return guardedOutcome, nil
}
prepared, err := prepareGitHubSyncPackage(workCtx, client, snapshot, target)
if err != nil {
return nil, err
}
defer func() {
if cleanupErr := prepared.download.Cleanup(); cleanupErr != nil {
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
}
}()
defer compensateSourceIngest(ctx, snapshot, prepared.ingestState)
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
return sourceHeartbeatOutcome(heartbeatErr)
}
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
if err != nil {
return nil, err
}
if !renewed {
return &sourceSyncOutcome{Stale: true}, nil
}
return activatePreparedGitHubSource(ctx, snapshot, actor, prepared)
}
func finalizeGitHubSyncFailure(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
resultErr error,
) error {
if resultErr == nil {
return nil
}
cleanupCtx, cancel := sourceCleanupContext(ctx)
defer cancel()
finalizerErr := persistGitHubSyncFailure(cleanupCtx, snapshot, resultErr)
if finalizerErr == nil {
return resultErr
}
logger.WarnF(
cleanupCtx,
"[PagesSource] finalize GitHub sync failure failed: source_id=%d source_error=%s error=%v",
snapshot.SourceID, safeGitHubSourceError(resultErr), finalizerErr,
)
// final fence 丢失表示已有新任务接管 runtime,不应覆盖;数据库
// finalizer 失败则保持可重试,避免继承永久错误或 provider deadline 分类。
if errors.Is(finalizerErr, errSourceFinalFence) {
return resultErr
}
return errors.New(errPagesSourceSyncFailed)
}
func persistGitHubSyncFailure(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
resultErr error,
) error {
var domainError *githubSourceProviderDomainError
if errors.As(resultErr, &domainError) && domainError.retryAt != nil {
return failGitHubCheckLease(ctx, snapshot, domainError.message, *domainError.retryAt)
}
return failSourceLease(ctx, snapshot, safeGitHubSourceError(resultErr))
}
func activatePreparedGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
actor string,
prepared *preparedGitHubSource,
) (*sourceSyncOutcome, error) {
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
deployment, reused, referenced, err := commitSourceDeployment(
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
prepared.target.Detail, prepared.target.DetailJSON, actor, prepared.manifest,
prepared.ingestState.Result, prepared.ingestState.HasIngest, prepared.target.RetryAt,
)
prepared.ingestState.Referenced = referenced
if errors.Is(err, errSourceFinalFence) {
return &sourceSyncOutcome{Stale: true}, nil
}
if err != nil {
return nil, err
}
prepared.ingestState.Referenced = prepared.ingestState.HasIngest && deployment.UploadID == prepared.ingestState.Result.Upload.ID
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, prepared.limits.HistoryCount, 0); pruneErr != nil {
logger.ErrorF(ctx, "[PagesSource] strict prune failed after GitHub sync: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, pruneErr)
}
view := buildDeploymentView(deployment)
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
}
func resolveAndGuardGitHubSync(
ctx context.Context,
client githubReleaseAPI,
snapshot *sourceExecutionSnapshot,
targetRevision string,
confirmedRevision string,
) (*githubSourceTarget, *sourceSyncOutcome, error) {
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
Tag: snapshot.ReleaseTag,
AssetName: snapshot.AssetName,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
return nil, nil, githubSourceDomainError(err)
}
if resolved.NotModified {
return nil, nil, errors.New(errPagesSourceReleaseNotFound)
}
target, err := buildGitHubSourceTarget(resolved.Release, resolved.Asset, resolved.RetryAt)
if err != nil {
return nil, nil, &githubSourceProviderDomainError{
message: safeGitHubSourceError(err),
permanent: isPermanentSourceSyncError(err),
retryAt: resolved.RetryAt,
}
}
guardedOutcome, err := guardGitHubSyncTarget(ctx, snapshot, target, targetRevision, confirmedRevision)
return target, guardedOutcome, err
}
func guardGitHubSyncTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
targetRevision string,
confirmedRevision string,
) (*sourceSyncOutcome, error) {
status := targetRuntimeStatus(target, snapshot.LastAppliedRevision, snapshot.LastAppliedDetail)
if targetRevision != "" && targetRevision != target.Revision {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, "", true, true)
}
if confirmedRevision != "" && (confirmedRevision != snapshot.LastSeenRevision || confirmedRevision != target.Revision) {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationStale, false, false)
}
if status == pagesSourceStatusAttention && confirmedRevision != target.Revision {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationNeeded, false, false)
}
if confirmedRevision != "" && status != pagesSourceStatusAttention {
return nil, errors.New(errPagesSourceConfirmationStale)
}
return nil, nil
}
func releaseGuardedGitHubTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
status string,
lastError string,
expedite bool,
staleSuccess bool,
) (*sourceSyncOutcome, error) {
err := releaseGitHubSyncWithoutActivation(ctx, snapshot, target, status, lastError, expedite, target.RetryAt)
if errors.Is(err, errSourceFinalFence) || (err == nil && staleSuccess) {
return &sourceSyncOutcome{Stale: true}, nil
}
if err != nil {
return nil, err
}
return nil, errors.New(lastError)
}
func prepareGitHubSyncPackage(
ctx context.Context,
client githubReleaseAPI,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
) (*preparedGitHubSource, error) {
limits := resolvePagesLimits(ctx)
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
download, err := client.Download(ctx, githubrelease.DownloadRequest{
Repository: snapshot.GitHubRepository,
Asset: target.Asset,
MaxBytes: limits.PackageBytes,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub download failed: source_id=%d repo=%s asset=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, snapshot.AssetName, err)
return nil, githubSourceDomainError(err)
}
prepared, err := inspectAndIngestGitHubPackage(ctx, snapshot, target, download, limits)
if err != nil {
if cleanupErr := download.Cleanup(); cleanupErr != nil {
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package after preparation failure failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
}
return nil, err
}
return prepared, nil
}
func inspectAndIngestGitHubPackage(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
download *githubrelease.DownloadResult,
limits pagesLimits,
) (*preparedGitHubSource, error) {
if download.SHA256 == "" || download.Path == "" {
return nil, errors.New(errPagesSourceSyncFailed)
}
if target.Detail.Digest != "" && "sha256:"+download.SHA256 != target.Detail.Digest {
return nil, errors.New(errPagesSourceDigestMismatch)
}
format, ok := pagesarchive.DetectFormatFromName(target.Asset.Name)
var err error
if !ok {
format, _, err = detectRemoteSourceFormat(download.Path, target.Asset.Name, "")
if err != nil {
return nil, err
}
}
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
if err != nil {
return nil, err
}
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
if err != nil {
return nil, err
}
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
if err != nil {
return nil, err
}
ingestState, err := resolveGitHubSourceIngest(ctx, snapshot, target, download, format)
if err != nil {
return nil, err
}
return &preparedGitHubSource{
target: target, download: download, format: format,
manifest: manifest, ingestState: ingestState, limits: limits,
}, nil
}
func resolveGitHubSourceIngest(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
download *githubrelease.DownloadResult,
format pagesarchive.Format,
) (*sourceIngestState, error) {
if _, err := findSourceDeployment(ctx, snapshot.ProjectID, snapshot.SourceIdentity, target.Revision); err == nil {
return &sourceIngestState{}, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
result, err := ingestPagesDeploymentPackageWithSource(
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
)
if err != nil {
return nil, err
}
return &sourceIngestState{Result: result, HasIngest: true}, nil
}
func releaseGitHubSyncWithoutActivation(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
status string,
lastError string,
expedite bool,
retryAt *time.Time,
) error {
now := time.Now()
nextCheckAt := nextCheckAfterGitHubResponse(snapshot, now, retryAt)
if expedite && snapshot.ReleaseSelector == githubReleaseSelectorLatest {
next := now.Add(initialCheckRetryDelay)
if retryAt != nil && retryAt.After(next) {
next = retryAt.UTC()
}
nextCheckAt = &next
}
updates := map[string]any{
"last_seen_revision": target.Revision,
"last_seen_detail": target.DetailJSON,
sourceRuntimeColumnSyncStatus: status,
sourceRuntimeColumnLastError: lastError,
sourceRuntimeColumnLastCheckedAt: &now,
"next_check_at": nextCheckAt,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errSourceFinalFence
}
return nil
}
func safeGitHubSourceError(err error) string {
if err == nil {
return errPagesSourceSyncFailed
}
message := strings.TrimSpace(err.Error())
for _, safeMessage := range []string{
errPagesSourceSyncFailed,
errPagesSourceReleaseNotFound,
errPagesSourceDigestInvalid,
errPagesSourceDigestMismatch,
errPagesSourceConfirmationNeeded,
errPagesSourceConfirmationStale,
errPagesPackageURLTooLarge,
errPagesPackageEmpty,
errPagesPackageUnsupported,
errPagesPackageInvalid,
errPagesPackageExtractedTooLarge,
errPagesPackageFileTooLarge,
errPagesEntryFileMissing,
} {
if message == safeMessage {
return safeMessage
}
}
return errPagesSourceSyncFailed
}
func githubSourceDomainError(err error) error {
message := errPagesSourceSyncFailed
retryAt, hasRetryAt := githubrelease.RetryAt(err)
var retryDeadline *time.Time
if hasRetryAt {
retryDeadline = &retryAt
}
if err == nil {
return &githubSourceProviderDomainError{message: message, permanent: false}
}
if githubrelease.IsDigestError(err) {
message = errPagesSourceDigestMismatch
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if githubrelease.IsNotFound(err) {
message = errPagesSourceReleaseNotFound
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
message = errPagesPackageURLTooLarge
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if errors.Is(err, githubrelease.ErrEmptyAsset) {
message = errPagesPackageEmpty
return &githubSourceProviderDomainError{message: message, permanent: true}
}
return &githubSourceProviderDomainError{
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline,
}
}
func shouldSkipGitHubActionRetry(err error) bool {
var domainError *githubSourceProviderDomainError
return errors.As(err, &domainError) && (domainError.permanent || domainError.retryAt != nil)
}
@@ -0,0 +1,111 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
)
func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) {
firstInput := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site",
ReleaseSelector: githubReleaseSelectorTag,
ReleaseTag: "release|foo",
AssetName: "bar.zip",
}
secondInput := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site",
ReleaseSelector: githubReleaseSelectorTag,
ReleaseTag: "release",
AssetName: "foo|bar.zip",
}
firstConfig, err := buildGitHubSourceConfig(firstInput)
if err != nil {
t.Fatalf("buildGitHubSourceConfig(first) error = %v, want nil", err)
}
secondConfig, err := buildGitHubSourceConfig(secondInput)
if err != nil {
t.Fatalf("buildGitHubSourceConfig(second) error = %v, want nil", err)
}
legacyIdentityInput := func(config githubSourceConfig) string {
return "github|" + config.Repository + "|" + config.Selector + "|" +
config.Tag + "|" + config.AssetName
}
if firstLegacy, secondLegacy := legacyIdentityInput(firstConfig), legacyIdentityInput(secondConfig); firstLegacy != secondLegacy {
t.Fatalf("legacy identity inputs differ: %q != %q; collision fixture is invalid", firstLegacy, secondLegacy)
}
if firstConfig.SourceIdentity == secondConfig.SourceIdentity {
t.Fatalf("length-prefixed identities collide: %q", firstConfig.SourceIdentity)
}
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-identity-collision")
firstSource, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, firstInput)
if got, want := firstSource.SourceIdentity, firstConfig.SourceIdentity; got != want {
t.Fatalf("first source identity = %q, want %q", got, want)
}
checkedAt := time.Now().Add(-time.Minute)
syncedAt := time.Now().Add(-30 * time.Second)
nextCheckAt := time.Now().Add(time.Hour)
leaseExpiresAt := time.Now().Add(time.Minute)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", firstSource.ID).
Updates(map[string]any{
"etag": `"old-etag"`,
"last_seen_revision": strings.Repeat("a", 64),
"last_seen_detail": `{"provider":"github_release","tag":"release|foo"}`,
"last_applied_revision": strings.Repeat("b", 64),
"last_applied_detail": `{"provider":"github_release","tag":"older"}`,
"sync_status": pagesSourceStatusSyncing,
"last_error": "old error",
"last_checked_at": &checkedAt,
"last_synced_at": &syncedAt,
"next_check_at": &nextCheckAt,
"lease_expires_at": &leaseExpiresAt,
"lease_token": "old-lease",
}).Error; err != nil {
t.Fatalf("seed runtime cursors error = %v, want nil", err)
}
secondSource, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, secondInput)
if secondSource.ID != firstSource.ID {
t.Errorf("updated source ID = %d, want unchanged %d", secondSource.ID, firstSource.ID)
}
if got, want := secondSource.SourceIdentity, secondConfig.SourceIdentity; got != want {
t.Errorf("updated source identity = %q, want %q", got, want)
}
if got, want := secondSource.ConfigVersion, firstSource.ConfigVersion+1; got != want {
t.Errorf("updated source config version = %d, want %d", got, want)
}
if runtime.ETag != "" || runtime.LastSeenRevision != "" || runtime.LastSeenDetail != "" ||
runtime.LastAppliedRevision != "" || runtime.LastAppliedDetail != "" {
t.Errorf("identity change retained runtime cursors: %+v", runtime)
}
if runtime.LastCheckedAt != nil || runtime.LastSyncedAt != nil || runtime.NextCheckAt != nil {
t.Errorf(
"identity change retained runtime timestamps: checked=%v synced=%v next=%v",
runtime.LastCheckedAt,
runtime.LastSyncedAt,
runtime.NextCheckAt,
)
}
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" ||
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
t.Errorf(
"identity change retained runtime state: status=%q error=%q lease=(%q, %v)",
runtime.SyncStatus,
runtime.LastError,
runtime.LeaseToken,
runtime.LeaseExpiresAt,
)
}
}
@@ -0,0 +1,888 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/hibiken/asynq"
"gorm.io/gorm"
)
type fakeGitHubReleaseClient struct {
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
}
func (client *fakeGitHubReleaseClient) Resolve(
ctx context.Context,
request githubrelease.ResolveRequest,
) (githubrelease.ResolveResult, error) {
return client.resolve(ctx, request)
}
func (client *fakeGitHubReleaseClient) Download(
ctx context.Context,
request githubrelease.DownloadRequest,
) (*githubrelease.DownloadResult, error) {
return client.download(ctx, request)
}
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
t.Helper()
previous := newGitHubReleaseClient
newGitHubReleaseClient = func() githubReleaseAPI { return client }
t.Cleanup(func() { newGitHubReleaseClient = previous })
}
func mustConfigureGitHubSourceWithoutDispatch(
t *testing.T,
ctx context.Context,
projectID uint,
input SourceUpdateInput,
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
t.Helper()
if err := validateGitHubSourceInput(input); err != nil {
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
}
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
_, err := updateGitHubSourceTx(tx, projectID, input)
return err
}); err != nil {
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
}
return mustLoadPagesSource(t, ctx, projectID)
}
func mustLoadPagesSource(
t *testing.T,
ctx context.Context,
projectID uint,
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
t.Helper()
var source model.PagesProjectSource
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
}
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
}
return &source, &runtime
}
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
ctx := setupPagesSourceTest(t)
setupPagesSourceDispatchTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-config")
input := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site.git",
}
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
if err != nil {
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
}
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
}
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
if err != nil {
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
}
var actionPayload SourceActionPayload
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
}
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
}
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
t.Errorf("GitHubRepository = %q, want %q", got, want)
}
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
t.Errorf("ReleaseSelector = %q, want %q", got, want)
}
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
t.Errorf("AssetName = %q, want %q", got, want)
}
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
}
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
t.Errorf("SourceIdentity = %q, want %q", got, want)
}
if runtime.NextCheckAt == nil {
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
}
var taskCount int64
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
t.Fatalf("count initial checks error = %v, want nil", err)
}
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
}
var noOpTaskCount int64
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
t.Fatalf("count no-op checks error = %v, want nil", err)
}
if noOpTaskCount != taskCount {
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
}
secret := "provider-switch-secret"
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURLSet: true,
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
}); err != nil {
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
}
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
}
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
}
github, _ := mustLoadPagesSource(t, ctx, project.ID)
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
}
}
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
}, "user:9")
if err != nil {
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
}
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
}
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
runtime.LastError != errPagesSourceInitialCheckWarning {
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
}
}
func TestGitHubSourceRejectsUnsafeOrPhaseThreeFields(t *testing.T) {
tests := []SourceUpdateInput{
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AutoUpdateEnabled: true},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
}
for _, input := range tests {
if err := validateGitHubSourceInput(input); err == nil {
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
}
}
}
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
tests := []SourceUpdateInput{
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist?channel=stable&part#1.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: " dist.zip ",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
ReleaseSelector: "tag",
ReleaseTag: "release/v1#stable&build=1",
AssetName: "dist.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b.git",
ReleaseSelector: "tag",
ReleaseTag: "@",
AssetName: "dist.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
ReleaseSelector: "tag",
ReleaseTag: "release/v1.LOCK",
AssetName: "dist.zip",
},
}
for _, input := range tests {
if err := validateGitHubSourceInput(input); err != nil {
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
}
}
}
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
staleVersion := source.ConfigVersion
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
t.Fatalf("increment source config version error = %v, want nil", err)
}
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
}
}
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-check")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
appliedRevision := strings.Repeat("a", 64)
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"etag": `"old-etag"`,
"last_applied_revision": appliedRevision,
"last_applied_detail": appliedDetail,
}).Error; err != nil {
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
}
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
var gotETag string
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
gotETag = request.ETag
return githubrelease.ResolveResult{
ETag: `"new-etag"`,
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called during check, want resolve only")
return nil, nil
},
})
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if err != nil || outcome != sourceLeaseAcquired {
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
}
result, err := checkGitHubSource(ctx, snapshot)
if err != nil {
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
}
if result.Stale {
t.Error("checkGitHubSource() stale = true, want false")
}
if got, want := gotETag, `"old-etag"`; got != want {
t.Errorf("Resolve ETag = %q, want %q", got, want)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
}
view, err := GetSource(ctx, project.ID)
if err != nil {
t.Fatalf("GetSource() error = %v, want nil", err)
}
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
}
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
}
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
t.Errorf("preflight exact confirmation error = %v, want nil", err)
}
}
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-304")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called for 304 check")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
}
var deployments int64
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
t.Fatalf("count deployments error = %v, want nil", err)
}
if deployments != 0 {
t.Errorf("deployments after check = %d, want 0", deployments)
}
}
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
appliedRevision := strings.Repeat("a", 64)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"last_applied_revision": appliedRevision,
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
}).Error; err != nil {
t.Fatalf("seed applied runtime error = %v, want nil", err)
}
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "100", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after target mismatch")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
if err != nil {
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
}
if outcome == nil || !outcome.Stale {
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusAttention {
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
}
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
}
var deployments int64
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
t.Fatalf("count mismatch deployments error = %v, want nil", err)
}
if deployments != 0 {
t.Errorf("target mismatch deployments = %d, want 0", deployments)
}
}
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
return nil, errors.New("unexpected")
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"lease_token": "new-owner",
"lease_expires_at": time.Now().Add(time.Minute),
"sync_status": pagesSourceStatusSyncing,
"last_error": "new-owner-state",
}).Error; err != nil {
t.Fatalf("replace lease owner error = %v, want nil", err)
}
result, err := checkGitHubSource(ctx, snapshot)
if err != nil {
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
}
if result == nil || !result.Stale {
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
}
}
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
}
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
return nil, errors.New("unexpected")
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
_, err := checkGitHubSource(ctx, snapshot)
if err == nil || err.Error() != errPagesSourceSyncFailed {
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
}
if !shouldSkipGitHubActionRetry(err) {
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
}
}
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "1", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after invalid check target")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
_, err := checkGitHubSource(ctx, snapshot)
if err == nil || err.Error() != errPagesSourceDigestInvalid {
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
}
if !isPermanentSourceSyncError(err) {
t.Error("invalid check target classification = retryable, want permanent")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
t.Errorf(
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
errPagesSourceDigestInvalid, retryAt,
)
}
}
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "1", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after invalid sync target")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
if err == nil || err.Error() != errPagesSourceDigestInvalid {
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
}
if !isPermanentSourceSyncError(err) {
t.Error("invalid sync target classification = retryable, want permanent")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
t.Errorf(
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
errPagesSourceDigestInvalid, retryAt,
)
}
}
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
tests := []struct {
name string
status int
retryDate bool
}{
{name: "bad request", status: http.StatusBadRequest},
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
var retryAt *time.Time
if test.retryDate {
deadline := time.Now().Add(2 * time.Hour).UTC()
retryAt = &deadline
}
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
}
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after provider check failure")
return nil, nil
},
})
raw, err := json.Marshal(SourceActionPayload{
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
Action: sourceActionCheck, Actor: "user:7",
})
if err != nil {
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
}
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
}
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
}
})
}
}
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist.zip",
})
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
packageHash := sha256.Sum256(packageBytes)
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
retryAt := time.Now().Add(2 * time.Hour).UTC()
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
client := &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
},
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
path := filepath.Join(t.TempDir(), "download")
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
}
return &githubrelease.DownloadResult{
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
}, nil
},
}
useFakeGitHubReleaseClient(t, client)
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
if err != nil {
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
}
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
}
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
if err != nil {
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
}
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
}
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
}
if got, want := deployment.SourceLabel, "release/v2"; got != want {
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
}
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
deployment.CreatedBy != "user:7" {
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
}
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
}
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
}
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
if err != nil {
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
}
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
}
}
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist.zip",
})
release := githubrelease.Release{ID: "300", Tag: "v3"}
asset := githubrelease.Asset{
ID: "12", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
}
target, err := buildGitHubSourceTarget(release, asset, nil)
if err != nil {
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
}
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"last_seen_revision": target.Revision,
"last_seen_detail": target.DetailJSON,
"last_applied_revision": strings.Repeat("a", 64),
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
"sync_status": pagesSourceStatusAttention,
}).Error; err != nil {
t.Fatalf("seed replacement cursor error = %v, want nil", err)
}
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
packageHash := sha256.Sum256(packageBytes)
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
path := filepath.Join(t.TempDir(), "confirmed.zip")
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
}
return &githubrelease.DownloadResult{
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
}, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
if err != nil {
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
}
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
}
}
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
handler := &SourceActionHandler{}
revision := strings.Repeat("a", 64)
invalid := []SourceActionPayload{
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
}
for _, payload := range invalid {
raw, _ := json.Marshal(payload)
if normalized, err := handler.ValidatePayload(raw); err == nil {
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
}
}
valid := []SourceActionPayload{
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", ConfirmedRevision: revision},
}
for _, payload := range valid {
raw, _ := json.Marshal(payload)
if _, err := handler.ValidatePayload(raw); err != nil {
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
}
}
}
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
tests := []struct {
name string
provider error
want string
permanent bool
skipRetry bool
}{
{
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
},
{
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
},
{
name: "rate limit", provider: &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: 429,
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
},
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
},
{
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
},
{
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
domainErr := githubSourceDomainError(test.provider)
if got := domainErr.Error(); got != test.want {
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
}
var typedDomainError *githubSourceProviderDomainError
if !errors.As(domainErr, &typedDomainError) {
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
}
if got := typedDomainError.permanent; got != test.permanent {
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
}
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
}
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
}
})
}
}
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called for stale confirmation")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
if err == nil || err.Error() != errPagesSourceConfirmationStale {
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
}
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
if loadErr != nil {
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
}
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
}
}
+4 -4
View File
@@ -822,8 +822,8 @@ func fenceDeploymentActivationSource(
audit.SourceIdentity = state.Source.SourceIdentity
audit.AutoDisabled = state.Source.AutoUpdateEnabled
if err := tx.Model(state.Source).Updates(map[string]any{
"config_version": state.Source.ConfigVersion + 1,
"auto_update_enabled": false,
sourceColumnConfigVersion: state.Source.ConfigVersion + 1,
sourceColumnAutoUpdateEnabled: false,
}).Error; err != nil {
return err
}
@@ -856,8 +856,8 @@ func switchActiveDeploymentTx(
return err
}
if err := tx.Model(deployment).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &now,
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
"activated_at": &now,
}).Error; err != nil {
return err
}
+18 -3
View File
@@ -45,6 +45,17 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
errPagesSourceRemoteURLMode,
errPagesSourceRemoteURLInvalid,
errPagesSourceNetworkPolicy,
errPagesSourceGitHubFields,
errPagesSourceRepositoryInvalid,
errPagesSourceSelectorInvalid,
errPagesSourceAssetNameInvalid,
errPagesSourceCheckInterval,
errPagesSourceAutoNotAvailable,
errPagesSourceReleaseNotFound,
errPagesSourceDigestInvalid,
errPagesSourceDigestMismatch,
errPagesSourceConfirmationNeeded,
errPagesSourceConfirmationStale,
errPagesSourceCheckUnsupported,
errPagesSourceActionInvalid:
response.AbortBadRequest(c, err.Error())
@@ -253,7 +264,7 @@ func GetSourceHandler(c *gin.Context) {
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
// @Summary 更新 Pages 部署源
// @Description Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
// @Tags openflare-pages
// @Accept json
// @Produce json
@@ -275,7 +286,11 @@ func UpdateSourceHandler(c *gin.Context) {
if !decodeStrictJSON(c, &input, false) {
return
}
result, err := UpdateSource(c.Request.Context(), projectID, input)
actor, ok := currentPagesActor(c)
if !ok {
return
}
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
if handleSourceLogicError(c, err) {
return
}
@@ -309,7 +324,7 @@ func DeleteSourceHandler(c *gin.Context) {
// CheckSourceHandler 请求检查 Pages 部署源。
// @Summary 检查 Pages 部署源
// @Description Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
+108 -33
View File
@@ -26,7 +26,7 @@ const (
PagesSourceTypeManual = "manual"
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
PagesSourceTypeRemoteURL = "remote_url"
// PagesSourceTypeGitHubRelease is reserved for Phase 2.
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
PagesSourceTypeGitHubRelease = "github_release"
pagesSourceStatusIdle = "idle"
@@ -37,6 +37,10 @@ const (
pagesSourceStatusAttention = "attention"
defaultRemoteAssetLabel = "pages-package"
defaultGitHubAssetName = "dist.zip"
defaultCheckInterval = 60
minimumCheckInterval = 5
maximumCheckInterval = 1440
)
// SourceUpdateInput is the discriminated source configuration payload.
@@ -72,7 +76,7 @@ type SourceView struct {
ReleaseSelector string `json:"release_selector,omitempty"`
ReleaseTag string `json:"release_tag,omitempty"`
AssetName string `json:"asset_name,omitempty"`
AutoUpdateEnabled bool `json:"auto_update_enabled,omitempty"`
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
UpdateAvailable bool `json:"update_available,omitempty"`
@@ -99,10 +103,15 @@ type SourceUpdateResult struct {
}
type sourceDetail struct {
Provider string `json:"provider"`
Label string `json:"label"`
AssetName string `json:"asset_name,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
Provider string `json:"provider"`
DisplayName string `json:"display_name,omitempty"`
Tag string `json:"tag,omitempty"`
LegacyLabel string `json:"label,omitempty"`
AssetName string `json:"asset_name,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
AssetID string `json:"asset_id,omitempty"`
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
Digest string `json:"digest,omitempty"`
}
type remoteSourceConfig struct {
@@ -126,14 +135,43 @@ func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
return buildSourceView(source, runtime)
}
// UpdateSource creates or updates a Remote URL source and its 1:1 runtime row.
// UpdateSource creates or updates a source. Direct callers use the system actor;
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
if err := validateRemoteSourceInput(input); err != nil {
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
}
// UpdateSourceAs persists source configuration and queues the first GitHub check
// after commit when the GitHub configuration was materially changed.
func UpdateSourceAs(
ctx context.Context,
projectID uint,
input SourceUpdateInput,
actor string,
) (*SourceUpdateResult, error) {
if !validPagesSourceActor(actor) {
return nil, errors.New(errPagesSourceActionInvalid)
}
if err := validateSourceUpdateInput(input); err != nil {
return nil, err
}
changed := false
var persistedSource model.PagesProjectSource
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
return updateRemoteSourceTx(tx, projectID, input)
var err error
switch strings.TrimSpace(input.SourceType) {
case PagesSourceTypeRemoteURL:
changed, err = updateRemoteSourceTx(tx, projectID, input)
case PagesSourceTypeGitHubRelease:
changed, err = updateGitHubSourceTx(tx, projectID, input)
default:
err = errors.New(errPagesSourceTypeUnsupported)
}
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
return err
}
return tx.Where("project_id = ?", projectID).First(&persistedSource).Error
})
if err != nil {
return nil, err
@@ -143,24 +181,34 @@ func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput)
if err != nil {
return nil, err
}
return &SourceUpdateResult{Source: view, Warning: ""}, nil
result := &SourceUpdateResult{Source: view, Warning: ""}
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
if dispatchErr != nil {
result.Warning = errPagesSourceInitialCheckWarning
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
} else {
result.CheckTask = receipt
}
}
return result, nil
}
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) error {
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
var project model.PagesProject
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
return err
return false, err
}
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
if err != nil {
return err
return false, err
}
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
if err != nil {
return err
return false, err
}
if !hasExisting {
return createRemoteSourceTx(tx, projectID, config)
return true, createRemoteSourceTx(tx, projectID, config)
}
return updateExistingRemoteSourceTx(tx, existing, config)
}
@@ -227,33 +275,33 @@ func updateExistingRemoteSourceTx(
tx *gorm.DB,
existing *model.PagesProjectSource,
config remoteSourceConfig,
) error {
) (bool, error) {
if !remoteSourceConfigChanged(existing, config) {
return nil
return false, nil
}
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", existing.ID).
First(&runtime).Error; err != nil {
return err
return false, err
}
identityChanged := existing.SourceIdentity != config.Identity
if err := tx.Model(existing).Updates(map[string]any{
"source_type": PagesSourceTypeRemoteURL,
"remote_url": config.URL,
"remote_network_policy": config.Policy,
"github_repository": "",
"release_selector": "",
"release_tag": "",
"asset_name": "",
"auto_update_enabled": false,
"check_interval_minutes": 0,
"config_version": existing.ConfigVersion + 1,
"source_identity": config.Identity,
"source_type": PagesSourceTypeRemoteURL,
"remote_url": config.URL,
"remote_network_policy": config.Policy,
"github_repository": "",
"release_selector": "",
"release_tag": "",
"asset_name": "",
sourceColumnAutoUpdateEnabled: false,
"check_interval_minutes": 0,
sourceColumnConfigVersion: existing.ConfigVersion + 1,
"source_identity": config.Identity,
}).Error; err != nil {
return err
return false, err
}
return resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
return true, resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
}
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
@@ -328,6 +376,19 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
return nil
}
func validateSourceUpdateInput(input SourceUpdateInput) error {
switch strings.TrimSpace(input.SourceType) {
case PagesSourceTypeRemoteURL:
return validateRemoteSourceInput(input)
case PagesSourceTypeGitHubRelease:
return validateGitHubSourceInput(input)
case "":
return errors.New(errPagesSourceTypeRequired)
default:
return errors.New(errPagesSourceTypeUnsupported)
}
}
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
if input.RemoteURLSet {
return strings.TrimSpace(input.RemoteURL), nil
@@ -430,7 +491,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
view.ReleaseSelector = source.ReleaseSelector
view.ReleaseTag = source.ReleaseTag
view.AssetName = source.AssetName
view.AutoUpdateEnabled = source.AutoUpdateEnabled
autoUpdateEnabled := source.AutoUpdateEnabled
view.AutoUpdateEnabled = &autoUpdateEnabled
view.CheckIntervalMinutes = source.CheckIntervalMinutes
default:
return nil, errors.New(errPagesSourceTypeUnsupported)
@@ -441,7 +503,7 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
func revisionView(revision string, detailJSON string) *SourceRevisionView {
detail := sourceDetail{}
_ = unmarshalSourceDetail(detailJSON, &detail)
label := strings.TrimSpace(detail.Label)
label := sourceDetailLabel(detail)
if label == "" {
label = defaultRemoteAssetLabel
}
@@ -452,6 +514,19 @@ func revisionView(revision string, detailJSON string) *SourceRevisionView {
}
}
func sourceDetailLabel(detail sourceDetail) string {
if detail.Provider == githubSourceDetailProvider {
if label := strings.TrimSpace(detail.Tag); label != "" {
return label
}
return strings.TrimSpace(detail.LegacyLabel)
}
if label := strings.TrimSpace(detail.DisplayName); label != "" {
return label
}
return strings.TrimSpace(detail.LegacyLabel)
}
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
if detail == nil || strings.TrimSpace(raw) == "" {
return nil
@@ -24,10 +24,14 @@ const (
sourceRuntimeErrorMaxBytes = 512
sourceRevisionHexLength = 64
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
sourceColumnConfigVersion = "config_version"
sourceRuntimeColumnSyncStatus = "sync_status"
sourceRuntimeColumnLastError = "last_error"
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
sourceRuntimeColumnLeaseToken = "lease_token"
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
pagesDeploymentColumnStatus = "status"
)
type sourceLeaseOutcome string
@@ -50,6 +54,16 @@ type sourceExecutionSnapshot struct {
SourceIdentity string
RemoteURL string
RemoteNetworkPolicy string
GitHubRepository string
ReleaseSelector string
ReleaseTag string
AssetName string
CheckIntervalMinutes int
ETag string
LastSeenRevision string
LastSeenDetail string
LastAppliedRevision string
LastAppliedDetail string
RootDir string
EntryFile string
LeaseToken string
@@ -161,6 +175,16 @@ func loadSourceExecutionSnapshot(
SourceIdentity: source.SourceIdentity,
RemoteURL: source.RemoteURL,
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
GitHubRepository: source.GitHubRepository,
ReleaseSelector: source.ReleaseSelector,
ReleaseTag: source.ReleaseTag,
AssetName: source.AssetName,
CheckIntervalMinutes: source.CheckIntervalMinutes,
ETag: runtime.ETag,
LastSeenRevision: runtime.LastSeenRevision,
LastSeenDetail: runtime.LastSeenDetail,
LastAppliedRevision: runtime.LastAppliedRevision,
LastAppliedDetail: runtime.LastAppliedDetail,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
LeaseToken: token,
+27 -11
View File
@@ -226,12 +226,14 @@ func syncRemoteSource(
ctx,
snapshot,
prepared.Candidate.Checksum,
prepared.Candidate.Checksum,
prepared.Detail,
prepared.DetailJSON,
actor,
prepared.Manifest,
ingestState.Result,
ingestState.HasIngest,
nil,
)
ingestState.Referenced = referenced
if errors.Is(err, errSourceFinalFence) {
@@ -288,7 +290,7 @@ func prepareRemoteSource(
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
detailJSON, err := json.Marshal(detail)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
@@ -336,7 +338,7 @@ func resolveSourceIngest(
prepared.Candidate.Checksum,
snapshot.ProjectID,
snapshot.SourceID,
prepared.Detail.Label,
sourceDetailLabel(prepared.Detail),
prepared.Candidate.Format,
)
if err != nil {
@@ -384,12 +386,14 @@ func commitSourceDeployment(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
manifest *deploymentManifest,
ingestResult upload.IngestResult,
hasIngest bool,
nextCheckNotBefore *time.Time,
) (*model.PagesDeployment, bool, bool, error) {
if snapshot == nil || manifest == nil {
return nil, false, false, errors.New(errPagesSourceSyncFailed)
@@ -403,7 +407,7 @@ func commitSourceDeployment(
return err
}
target, targetReused, err := resolveSourceDeploymentTx(
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
)
if err != nil {
return err
@@ -417,7 +421,7 @@ func commitSourceDeployment(
if err := refreshSourceCommitLease(state, snapshot); err != nil {
return err
}
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
return err
}
committed = *target
@@ -491,6 +495,7 @@ func resolveSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
@@ -515,7 +520,7 @@ func resolveSourceDeploymentTx(
return nil, false, errSourceFinalFence
}
return createSourceDeploymentTx(
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult,
)
}
@@ -523,6 +528,7 @@ func createSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
@@ -541,7 +547,7 @@ func createSourceDeploymentTx(
target := &model.PagesDeployment{
ProjectID: state.Project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: revision,
Checksum: packageChecksum,
Status: model.PagesDeploymentStatusUploaded,
UploadID: ingestResult.Upload.ID,
FileCount: manifest.FileCount,
@@ -550,7 +556,7 @@ func createSourceDeploymentTx(
SourceType: state.Source.SourceType,
SourceIdentity: &identity,
SourceRevision: &revisionValue,
SourceLabel: detail.Label,
SourceLabel: sourceDetailLabel(detail),
SourceMeta: detailJSON,
TriggerType: pagesSourceTriggerManualSync,
}
@@ -629,6 +635,7 @@ func activateSourceDeploymentTx(
target *model.PagesDeployment,
revision string,
detailJSON string,
nextCheckNotBefore *time.Time,
) error {
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", state.Project.ID).
@@ -636,8 +643,8 @@ func activateSourceDeploymentTx(
return err
}
if err := tx.Model(target).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &state.Now,
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
"activated_at": &state.Now,
}).Error; err != nil {
return err
}
@@ -645,6 +652,15 @@ func activateSourceDeploymentTx(
return err
}
finishedAt := sourceCommitNow()
var nextCheckAt any
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
next = nextCheckNotBefore.UTC()
}
nextCheckAt = &next
}
result := tx.Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
state.Runtime.SourceID,
@@ -658,9 +674,9 @@ func activateSourceDeploymentTx(
"last_applied_detail": detailJSON,
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
sourceRuntimeColumnLastError: "",
"last_checked_at": &finishedAt,
sourceRuntimeColumnLastCheckedAt: &finishedAt,
"last_synced_at": &finishedAt,
"next_check_at": nil,
"next_check_at": nextCheckAt,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
})
@@ -388,12 +388,14 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
ctx,
snapshot,
deployment.Checksum,
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: deployment.SourceLabel},
deployment.Checksum,
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
deployment.SourceMeta,
"user:5",
&deploymentManifest{},
upload.IngestResult{},
false,
nil,
)
if !errors.Is(err, errSourceFinalFence) {
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
@@ -552,7 +554,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
SourceIdentity: &identity,
SourceRevision: &revision,
SourceLabel: "deleted.zip",
SourceMeta: `{"provider":"remote_url","label":"deleted.zip"}`,
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
TriggerType: pagesSourceTriggerManualSync,
}
if err := db.DB(ctx).Create(deployment).Error; err != nil {
@@ -575,12 +577,14 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
ctx,
snapshot,
revision,
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: "deleted.zip"},
`{"provider":"remote_url","label":"deleted.zip"}`,
revision,
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
`{"provider":"remote_url","display_name":"deleted.zip"}`,
"user:1",
manifest,
upload.IngestResult{},
false,
nil,
)
if !errors.Is(err, errSourceFinalFence) {
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
+113 -17
View File
@@ -77,7 +77,11 @@ func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
!validPagesSourceActor(input.Actor) ||
!validOptionalSourceRevision(input.TargetRevision) ||
!validOptionalSourceRevision(input.ConfirmedRevision) {
!validOptionalSourceRevision(input.ConfirmedRevision) ||
(input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "")) ||
(input.TargetRevision != "" && input.ConfirmedRevision != "") ||
(input.TargetRevision != "" && input.Actor != pagesSourceCreatedBySystem) ||
(input.ConfirmedRevision != "" && !strings.HasPrefix(input.Actor, "user:")) {
return nil, errors.New(errPagesSourceActionInvalid)
}
return json.Marshal(input)
@@ -110,10 +114,13 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
}
if source.SourceType != PagesSourceTypeRemoteURL {
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
}
if input.TargetRevision != "" || input.ConfirmedRevision != "" {
if source.SourceType == PagesSourceTypeRemoteURL && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
return nil, task.PermanentError(errPagesSourceActionInvalid)
}
if input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
return nil, task.PermanentError(errPagesSourceActionInvalid)
}
@@ -132,10 +139,43 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
result, err := syncRemoteSource(ctx, snapshot, input.Actor)
if input.Action == sourceActionCheck {
return executeGitHubCheckAction(ctx, snapshot)
}
return executeSourceSyncAction(ctx, &source, snapshot, input)
}
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
if checkErr != nil {
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
return nil, task.PermanentError(checkErr.Error())
}
return nil, errors.New(errPagesSourceSyncFailed)
}
if checkResult == nil || checkResult.Stale {
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
}
func executeSourceSyncAction(
ctx context.Context,
source *model.PagesProjectSource,
snapshot *sourceExecutionSnapshot,
input SourceActionPayload,
) (*task.TaskResult, error) {
var result *sourceSyncOutcome
var err error
if source.SourceType == PagesSourceTypeGitHubRelease {
result, err = syncGitHubSource(ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision)
} else {
result, err = syncRemoteSource(ctx, snapshot, input.Actor)
}
if err != nil {
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
if isPermanentSourceSyncError(err) {
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
return nil, task.PermanentError(errPagesSourceSyncFailed)
}
return nil, errors.New(errPagesSourceSyncFailed)
@@ -191,13 +231,19 @@ func isPermanentSourceSyncError(err error) bool {
}
message := err.Error()
return strings.Contains(message, errPagesPackageUnsupported) ||
strings.Contains(message, errPagesPackageURLTooLarge) ||
strings.Contains(message, errPagesPackageInvalid) ||
strings.Contains(message, errPagesPackageEmpty) ||
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
strings.Contains(message, errPagesPackageFileTooLarge) ||
strings.Contains(message, errPagesEntryFileMissing) ||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
strings.Contains(message, errPagesSourceNetworkPolicy)
strings.Contains(message, errPagesSourceNetworkPolicy) ||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
strings.Contains(message, errPagesSourceDigestInvalid) ||
strings.Contains(message, errPagesSourceDigestMismatch) ||
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
strings.Contains(message, errPagesSourceConfirmationStale)
}
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
@@ -207,8 +253,20 @@ func DispatchSourceAction(
action string,
actor string,
confirmedRevision string,
) (*SourceActionReceipt, error) {
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
}
func dispatchSourceActionByProject(
ctx context.Context,
projectID uint,
action string,
actor string,
targetRevision string,
confirmedRevision string,
) (*SourceActionReceipt, error) {
action = strings.TrimSpace(action)
targetRevision = strings.TrimSpace(targetRevision)
confirmedRevision = strings.TrimSpace(confirmedRevision)
if action != sourceActionCheck && action != sourceActionSync {
return nil, errors.New(errPagesSourceActionInvalid)
@@ -224,14 +282,8 @@ func DispatchSourceAction(
}
return nil, err
}
if source.SourceType != PagesSourceTypeRemoteURL {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
if action == sourceActionCheck {
return nil, errors.New(errPagesSourceCheckUnsupported)
}
if confirmedRevision != "" {
return nil, errors.New(errPagesSourceActionInvalid)
if err := validateSourceActionPreflight(ctx, &source, action, targetRevision, confirmedRevision); err != nil {
return nil, err
}
busy, err := sourceLeaseIsBusy(ctx, source.ID)
if err != nil {
@@ -240,14 +292,58 @@ func DispatchSourceAction(
if busy {
return nil, errors.New(errPagesSourceActionBusy)
}
return dispatchSourceActionSnapshot(ctx, source, action, actor, targetRevision, confirmedRevision, "manual")
}
func validateSourceActionPreflight(
ctx context.Context,
source *model.PagesProjectSource,
action string,
targetRevision string,
confirmedRevision string,
) error {
if source == nil {
return errors.New(errPagesSourceNotFound)
}
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
return errors.New(errPagesSourceTypeUnsupported)
}
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
return errors.New(errPagesSourceCheckUnsupported)
}
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
return errors.New(errPagesSourceActionInvalid)
}
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
return errors.New(errPagesSourceActionInvalid)
}
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
return err
}
}
return nil
}
func dispatchSourceActionSnapshot(
ctx context.Context,
source model.PagesProjectSource,
action string,
actor string,
targetRevision string,
confirmedRevision string,
triggeredBy string,
) (*SourceActionReceipt, error) {
if task.AsynqClient == nil {
return nil, errors.New(errPagesSourceTaskDispatchFailed)
}
handler := &SourceActionHandler{}
rawPayload, err := json.Marshal(SourceActionPayload{
SourceID: source.ID,
ConfigVersion: source.ConfigVersion,
Action: action,
Actor: actor,
TargetRevision: "",
TargetRevision: targetRevision,
ConfirmedRevision: confirmedRevision,
})
if err != nil {
@@ -257,9 +353,9 @@ func DispatchSourceAction(
if err != nil {
return nil, err
}
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, "manual")
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
if err != nil {
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", projectID, source.ID, action, err)
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
return nil, errors.New(errPagesSourceTaskDispatchFailed)
}
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
+29 -2
View File
@@ -30,6 +30,33 @@ func setupPagesSourceTest(t *testing.T) context.Context {
return t.Context()
}
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
tests := []struct {
name string
detail string
want string
}{
{
name: "remote",
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
want: "legacy.zip",
},
{
name: "github",
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
want: "v1.2.3",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
view := revisionView(strings.Repeat("a", 64), test.detail)
if view.Label != test.want {
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
}
})
}
}
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
t.Helper()
view, err := CreateProject(ctx, Input{
@@ -203,9 +230,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
Where("source_id = ?", source.ID).
Updates(map[string]any{
"last_seen_revision": seenRevision,
"last_seen_detail": `{"provider":"remote_url","label":"new.zip"}`,
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
"last_applied_revision": appliedRevision,
"last_applied_detail": `{"provider":"remote_url","label":"old.zip"}`,
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
"sync_status": pagesSourceStatusSyncing,
"lease_token": "in-flight",
"lease_expires_at": &future,