Add ready question production promotion

This commit is contained in:
hz
2026-07-26 01:17:55 -05:00
parent ef44347bf6
commit 447f080552
5 changed files with 434 additions and 18 deletions
+12 -13
View File
@@ -1,4 +1,4 @@
name: Promote UAT content to PRD
name: Promote ready UAT questions to PRD
on:
workflow_dispatch:
@@ -13,7 +13,7 @@ on:
default: ""
concurrency:
group: step3-content-promotion
group: step3-question-promotion
cancel-in-progress: false
jobs:
@@ -45,26 +45,26 @@ jobs:
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
fi
- name: Configure promotion credentials
- name: Configure question promotion credentials
env:
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
run: |
test -n "$SOURCE_CONNECTION"
test -n "$TARGET_CONNECTION"
kubectl create secret generic step3-content-promotion \
kubectl create secret generic step3-question-promotion \
--from-literal=source-connection="$SOURCE_CONNECTION" \
--from-literal=target-connection="$TARGET_CONNECTION" \
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
- name: Run promotion
- name: Run ready-question promotion
env:
MODE: ${{ inputs.mode }}
CONFIRMATION: ${{ inputs.confirmation }}
run: |
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
REVISION="${IMAGE##*:}"
POD="content-promotion-${GITHUB_RUN_NUMBER}"
POD="question-promotion-${GITHUB_RUN_NUMBER}"
APPLY_ARGS=""
if [ "$MODE" = "apply" ]; then
APPLY_ARGS="--apply --confirm $CONFIRMATION"
@@ -76,7 +76,7 @@ jobs:
name: ${POD}
namespace: step3-prd
labels:
app: step3-content-promotion
app: step3-question-promotion
spec:
restartPolicy: Never
containers:
@@ -86,7 +86,7 @@ jobs:
command: ["/bin/sh", "-c"]
args:
- >-
dotnet QuestionsApi.dll promote-content
dotnet QuestionsApi.dll promote-questions
--source-database ranaze_step3_uat
--target-database ranaze_step3_prd
--operator "${GITHUB_ACTOR:-gitea-operator}"
@@ -96,12 +96,12 @@ jobs:
- name: SOURCE_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
name: step3-question-promotion
key: source-connection
- name: TARGET_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
name: step3-question-promotion
key: target-connection
EOF
for attempt in $(seq 1 120); do
@@ -115,6 +115,5 @@ jobs:
- name: Remove promotion credentials and pod
if: always()
run: |
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
+5 -5
View File
@@ -57,9 +57,9 @@ The hostname change creates a new browser origin, so testers should expect to si
1. Create and protect the `production` branch, then merge the tested `main` revision.
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values.
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents.
6. Register the initial PRD account normally, then promote it manually:
```javascript
@@ -69,11 +69,11 @@ The hostname change creates a new browser origin, so testers should expect to si
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
## Content promotion behavior
## Question promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
## Verification and rollback
@@ -31,4 +31,129 @@ public sealed class ContentPromotionTests
ContentPromotionCommand.HashDocuments([first, second]),
ContentPromotionCommand.HashDocuments([second, first]));
}
[Fact]
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
{
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value
}));
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = DateTime.UtcNow
}));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
var first = ObjectId.GenerateNewId();
var second = ObjectId.GenerateNewId();
var questions = new[]
{
new BsonDocument
{
["ExternalId"] = "one",
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
},
new BsonDocument
{
["ExternalId"] = "two",
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
}
};
var result = QuestionPromotionCommand.CollectMediaIds(questions);
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
}
[Fact]
public void InvalidLocalQuestionMediaReferenceFailsValidation()
{
var question = new BsonDocument
{
["ExternalId"] = "bad-media",
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
};
var exception = Assert.Throws<InvalidOperationException>(
() => QuestionPromotionCommand.CollectMediaIds([question]));
Assert.Contains("bad-media", exception.Message);
}
[Fact]
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
{
var source = new[]
{
Question("insert", "new"),
Question("update", "new"),
Question("same", "same")
};
var target = new[]
{
Question("update", "old"),
new BsonDocument
{
["Stem"] = "same",
["ExternalId"] = "same",
["_id"] = ObjectId.GenerateNewId()
}
};
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
Assert.Equal(1, result.Inserted);
Assert.Equal(1, result.Updated);
Assert.Equal(1, result.Unchanged);
}
[Fact]
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
{
var first = Question("a", "one");
first["_id"] = ObjectId.GenerateNewId();
var second = Question("b", "two");
var equivalentFirst = new BsonDocument
{
["Stem"] = "one",
["_id"] = ObjectId.GenerateNewId(),
["ExternalId"] = "a"
};
Assert.Equal(
QuestionPromotionCommand.HashQuestions([first, second]),
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
}
[Fact]
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
{
using var output = new StringWriter();
var result = await QuestionPromotionCommand.RunAsync(
[
"--source-connection", "mongodb://source.invalid",
"--source-database", "uat",
"--target-connection", "mongodb://target.invalid",
"--target-database", "prd",
"--apply"
], output);
Assert.Equal(1, result);
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
}
private static BsonDocument Question(string externalId, string stem) => new()
{
["ExternalId"] = externalId,
["Stem"] = stem
};
}
+6
View File
@@ -17,6 +17,12 @@ if (args.FirstOrDefault() == "promote-content")
return;
}
if (args.FirstOrDefault() == "promote-questions")
{
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "promote-dynamic-questions")
{
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
@@ -0,0 +1,286 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
try
{
var options = Parse(args);
if (options.Apply && options.Confirmation != Confirmation)
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
&& options.SourceConnection == options.TargetConnection)
throw new ArgumentException("source and target databases must be different");
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = questions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToList();
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
if (media.Count != mediaIds.Count)
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
var sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
var targetCollection = target.GetCollection<BsonDocument>("Questions");
var targetQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
var comparison = ClassifyQuestions(questions, targetQuestions);
var summary = new PromotionSummary(
questions.Count,
media.Count,
comparison.Inserted,
comparison.Updated,
comparison.Unchanged,
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
HashQuestions(questions));
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
if (!options.Apply)
return 0;
await UpsertMediaAsync(target, media);
await UpsertQuestionsAsync(target, questions);
var verifiedQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var verification = ClassifyQuestions(questions, verifiedQuestions);
if (verification.Inserted != 0 || verification.Updated != 0)
throw new InvalidOperationException(
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
var verifiedMedia = mediaIds.Count == 0
? 0
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
if (verifiedMedia != mediaIds.Count)
throw new InvalidOperationException(
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
{
["PromotionId"] = Guid.NewGuid().ToString("N"),
["AppliedUtc"] = DateTime.UtcNow,
["Operator"] = options.Operator,
["BuildRevision"] = options.BuildRevision,
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
["Unchanged"] = summary.Unchanged,
["MediaAssets"] = summary.MediaAssets,
["TargetExtras"] = summary.TargetExtras,
["ContentHash"] = summary.ContentHash
});
await output.WriteLineAsync("Question promotion completed and verified.");
return 0;
}
catch (Exception exception)
{
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
return 1;
}
}
internal static bool IsEligible(BsonDocument question) =>
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
foreach (var question in questions)
{
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray)
continue;
foreach (var value in raw.AsBsonArray)
{
if (!value.IsString)
continue;
var reference = value.AsString.Trim();
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (ObjectId.TryParse(reference, out var direct))
ids.Add(direct);
else if (ObjectId.TryParse(candidate, out var fromPath))
ids.Add(fromPath);
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
}
}
return ids;
}
internal static QuestionComparison ClassifyQuestions(
IEnumerable<BsonDocument> sourceQuestions,
IEnumerable<BsonDocument> targetQuestions)
{
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
var inserted = 0;
var updated = 0;
var unchanged = 0;
foreach (var source in sourceQuestions)
{
var externalId = ExternalId(source);
if (!target.TryGetValue(externalId, out var existing))
inserted++;
else if (EquivalentQuestion(source, existing))
unchanged++;
else
updated++;
}
return new QuestionComparison(inserted, updated, unchanged);
}
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
private static BsonValue Canonicalize(BsonValue value)
{
if (value.IsBsonDocument)
return new BsonDocument(value.AsBsonDocument
.OrderBy(element => element.Name, StringComparer.Ordinal)
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
if (value.IsBsonArray)
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
return value;
}
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
foreach (var source in questions)
{
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
replacement,
new ReplaceOptions { IsUpsert = true });
}
}
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
{
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
foreach (var source in media)
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
source,
new ReplaceOptions { IsUpsert = true });
}
private static Task WriteSummaryAsync(
TextWriter output,
string mode,
PromotionOptions options,
PromotionSummary summary) =>
output.WriteLineAsync(JsonSerializer.Serialize(new
{
mode,
scope = "questions",
eligibility = "published_unflagged",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,
mediaAssets = summary.MediaAssets,
inserted = summary.Inserted,
updated = summary.Updated,
unchanged = summary.Unchanged,
targetExtras = summary.TargetExtras,
contentHash = summary.ContentHash
}));
private static PromotionOptions Parse(IReadOnlyList<string> args)
{
string Required(string name, string environmentName)
{
var index = args.IndexOf($"--{name}");
var value = index >= 0 && index + 1 < args.Count
? args[index + 1]
: Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"--{name} is required")
: value;
}
string Optional(string name, string fallback)
{
var index = args.IndexOf($"--{name}");
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
}
return new PromotionOptions(
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
Required("source-database", "SOURCE_MONGODB_DATABASE"),
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
Required("target-database", "TARGET_MONGODB_DATABASE"),
args.Contains("--apply"),
Optional("confirm", ""),
Optional("operator", "unknown"),
Optional("build-revision", "unknown"));
}
private sealed record PromotionOptions(
string SourceConnection,
string SourceDatabase,
string TargetConnection,
string TargetDatabase,
bool Apply,
string Confirmation,
string Operator,
string BuildRevision);
internal sealed record QuestionComparison(int Inserted, int Updated, int Unchanged);
internal sealed record PromotionSummary(
int EligibleQuestions,
int MediaAssets,
int Inserted,
int Updated,
int Unchanged,
int TargetExtras,
string ContentHash);
}